From ddd840c30504fde3ecdba6287e921af375fe5a94 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:17:52 -0600 Subject: [PATCH 1/8] search implementation --- .../firestore/dev/src/pipelines/expression.ts | 353 +++++++++ .../firestore/dev/src/pipelines/pipelines.ts | 27 + .../firestore/dev/src/pipelines/stage.ts | 55 ++ .../firestore/dev/system-test/pipeline.ts | 674 +++++++++++++++++- handwritten/firestore/types/firestore.d.ts | 353 +++++++++ 5 files changed, 1424 insertions(+), 38 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index 20054a59b81..dd6cc73452d 100644 --- a/handwritten/firestore/dev/src/pipelines/expression.ts +++ b/handwritten/firestore/dev/src/pipelines/expression.ts @@ -23,11 +23,14 @@ import { fieldOrExpression, isFirestoreValue, isString, + toField, valueToDefaultExpr, vectorToExpr, } from './pipeline-util'; import {HasUserData, Serializer, validateUserInput} from '../serializer'; import {cast} from '../util'; +import {GeoPoint} from '../geo-point'; +import {OptionsUtil} from './options-util'; /** * @beta @@ -3100,6 +3103,85 @@ export abstract class Expression ]).asBoolean(); } + /** + * 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(contant(2.2)), field('tireWidth').lessThanOrEqual(constant(2.4))) + * ``` + * + * @param lowerBound - Lower bound (inclusive) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ + between(lowerBound: Expression, upperBound: Expression): BooleanExpression; + + /** + * 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) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ + between(lowerBound: unknown, upperBound: unknown): BooleanExpression; + + between(lowerBound: unknown, upperBound: unknown): BooleanExpression { + return new FunctionExpression('between', [ + this, + valueToDefaultExpr(lowerBound), + valueToDefaultExpr(upperBound), + ]).asBoolean(); + } + + /** + * Evaluates to an HTML-formatted text snippet that renders terms matching + * the search query in `bold`. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param rquery Define the search query using the search DTS (TODO(search) link). + */ + snippet(rquery: string): Expression; + + /** + * Evaluates to an HTML-formatted text snippet that renders terms matching + * the search query in `bold`. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param options Define how snippeting behaves. + */ + snippet(options: firestore.Pipelines.SnippetOptions): Expression; + + snippet( + queryOrOptions: string | firestore.Pipelines.SnippetOptions, + ): Expression { + const options: firestore.Pipelines.SnippetOptions = isString(queryOrOptions) + ? {rquery: queryOrOptions} + : queryOrOptions; + const rquery = options.rquery; + const internalOptions = { + maxSnippetWidth: options.maxSnippetWidth, + maxSnippets: options.maxSnippets, + separator: options.separator, + }; + return new SnippetExpression([this, constant(rquery)], internalOptions); + } + // TODO(new-expression): Add new expression method definitions above this line /** @@ -3351,6 +3433,35 @@ export class Field readonly expressionType: firestore.Pipelines.ExpressionType = 'Field'; selectable = true as const; + /** + * Perform a full-text search on this field. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param rquery Define the search query using the rquery DTS. + */ + matches(rquery: string | Expression): BooleanExpression { + return new FunctionExpression('matches', [ + this, + valueToDefaultExpr(rquery), + ]).asBoolean(); + } + + /** + * Evaluates to the distance in meters between the location specified + * by this field and the query location. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param location - Compute distance to this GeoPoint. + */ + geoDistance(location: GeoPoint | Expression): Expression { + return new FunctionExpression('geo_distance', [ + this, + valueToDefaultExpr(location), + ]).asBoolean(); + } + /** * @beta * @internal @@ -3686,6 +3797,53 @@ export class FunctionExpression extends Expression { } } +/** + * SnippetExpression extends from FunctionExpression because it + * supports options and requires the options util. + */ +export class SnippetExpression extends FunctionExpression { + /** + * @private + * @internal + */ + get _optionsUtil(): OptionsUtil { + return new OptionsUtil({ + maxSnippetWidth: { + serverName: 'max_snippet_width', + }, + maxSnippets: { + serverName: 'max_snippets', + }, + separator: { + serverName: 'separator', + }, + }); + } + + /** + * @hideconstructor + */ + constructor( + params: Expression[], + private _options?: {}, + ) { + super('snippet', params); + } + + _toProto(serializer: Serializer): api.IValue { + return { + functionValue: { + ...super._toProto(serializer), + options: this._optionsUtil.getOptionsProto( + serializer, + this._options ?? {}, + {}, + ), + }, + }; + } +} + /** * @beta * This class defines the base class for Firestore `Pipeline` functions, which can be evaluated within pipeline @@ -10194,6 +10352,201 @@ export function isType( return fieldOrExpression(fieldNameOrExpression).isType(type); } +/** + * Perform a full-text search on the specified field. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param searchField Search the specified field. + * @param rquery Define the search query using the search DTS. + */ +export function matches( + searchField: string | Field, + rquery: string | Expression, +): BooleanExpression { + return toField(searchField).matches(rquery); +} + +/** + * Perform a full-text search on the document. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param rquery Define the search query using the rquery DTS. + */ +export function documentMatches( + rquery: string | Expression, +): BooleanExpression { + return new FunctionExpression('document_matches', [ + valueToDefaultExpr(rquery), + ]).asBoolean(); +} + +/** + * Evaluates to the search score that reflects the topicality of the document + * to all of the text predicates (`queryMatch`) + * in the search query. If `SearchOptions.query` is not set or does not contain + * any text predicates, then this topicality score will always be `0`. + * + * @remarks This Expression can only be used within a `Search` stage. + */ +export function score(): Expression { + return new FunctionExpression('search_score', []).asBoolean(); +} + +/** + * Evaluates to an HTML-formatted text snippet that highlights terms matching + * the search query in `bold`. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param searchField Search the specified field for matching terms. + * @param rquery Define the search query using the search DTS (TODO(search) link). + */ +export function snippet( + searchField: string | Field, + rquery: string, +): Expression; + +/** + * Evaluates to an HTML-formatted text snippet that highlights terms matching + * the search query in `bold`. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param searchField Search the specified field for matching terms. + * @param options Define the search query using the search DTS (TODO(search) link). + */ +export function snippet( + searchField: string | Field, + options: firestore.Pipelines.SnippetOptions, +): Expression; +export function snippet( + field: string | Field, + queryOrOptions: string | firestore.Pipelines.SnippetOptions, +): Expression { + return toField(field).snippet( + isString(queryOrOptions) ? {rquery: queryOrOptions} : queryOrOptions, + ); +} + +/** + * Evaluates to the distance in meters between the location in the specified + * field and the query location. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param fieldName - Specifies the field in the document which contains + * the first GeoPoint for distance computation. + * @param location - Compute distance to this GeoPoint. + */ +export function geoDistance( + fieldName: string | Field, + location: GeoPoint | Expression, +): Expression { + return toField(fieldName).geoDistance(location); +} + +/** + * Evaluates if the value in the field specified by `fieldName` is between + * the evaluated values for `lowerBound` (inclusive) and `upperBound` (inclusive). + * + * @example + * ``` + * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + * between('tireWidth', constant(2.2), constant(2.4)) + * + * // This is functionally equivalent to + * and(greaterThanOrEqual('tireWidth', constant(2.2)), lessThanOrEqual('tireWidth', constant(2.4))) + * ``` + * + * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. + * @param lowerBound - Lower bound (inclusive) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ +export function between( + fieldName: string, + lowerBound: Expression, + upperBound: Expression, +): BooleanExpression; + +/** + * Evaluates if the value in the field specified by `fieldName` is between + * the values for `lowerBound` (inclusive) and `upperBound` (inclusive). + * + * @example + * ``` + * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + * between('tireWidth', 2.2, 2.4) + * + * // This is functionally equivalent to + * and(greaterThanOrEqual('tireWidth', 2.2), lessThanOrEqual('tireWidth', 2.4)) + * ``` + * + * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. + * @param lowerBound - Lower bound (inclusive) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ +export function between( + fieldName: string, + lowerBound: unknown, + upperBound: unknown, +): BooleanExpression; + +/** + * Evaluates if the result of the specified `expression` is between + * the results of `lowerBound` (inclusive) and `upperBound` (inclusive). + * + * @example + * ``` + * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + * between(field('tireWidth'), constant(2.2), constant(2.4)) + * + * // This is functionally equivalent to + * and(greaterThanOrEqual(field('tireWidth'), constant(2.2)), lessThanOrEqual(field('tireWidth'), constant(2.4))) + * ``` + * + * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. + * @param lowerBound - Lower bound (inclusive) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ +export function between( + expression: Expression, + lowerBound: Expression, + upperBound: Expression, +): BooleanExpression; + +/** + * Evaluates if the result of the specified `expression` is between + * the `lowerBound` (inclusive) and `upperBound` (inclusive). + * + * @example + * ``` + * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + * between(field('tireWidth'), 2.2, 2.4) + * + * // This is functionally equivalent to + * and(greaterThanOrEqual(field('tireWidth'), 2.2), lessThanOrEqual(field('tireWidth'), 2.4)) + * ``` + * + * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. + * @param lowerBound - Lower bound (inclusive) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ +export function between( + expression: Expression, + lowerBound: unknown, + upperBound: unknown, +): BooleanExpression; + +export function between( + expression: Expression | string, + lowerBound: unknown, + upperBound: unknown, +): BooleanExpression { + return fieldOrExpression(expression).between(lowerBound, upperBound); +} + // TODO(new-expression): Add new top-level expression function definitions above this line /** diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 2844c29ca57..0a5c01f1a2f 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -59,6 +59,7 @@ import { constant, _mapValue, field, + documentMatches, } from './expression'; import { AddFields, @@ -95,6 +96,8 @@ import { InternalDocumentsStageOptions, InternalCollectionGroupStageOptions, InternalCollectionStageOptions, + Search, + InternalSearchStageOptions, } from './stage'; import {StructuredPipeline} from './structured-pipeline'; import Selectable = FirebaseFirestore.Pipelines.Selectable; @@ -1420,6 +1423,30 @@ export class Pipeline implements firestore.Pipelines.Pipeline { return this._addStage(new Unnest(internalOptions)); } + /** + * Add a search stage to the Pipeline. + * + * @remarks This must be the first stage of the pipeline. + * @remarks A limited set of expressions are supported in the search stage. + * + * @param options - An object that specifies required and optional parameters + * for the stage. + * @return A new `Pipeline` object with this stage appended to the stage list. + */ + search(options: firestore.Pipelines.SearchStageOptions): Pipeline { + const normalizedQuery = isString(options.query) + ? documentMatches(options.query) + : (options.query as BooleanExpression | undefined); + + const internalOptions: InternalSearchStageOptions = { + ...options, + query: normalizedQuery, + }; + + // Add stage to the pipeline + return this._addStage(new Search(internalOptions)); + } + /** * @beta * Sorts the documents from previous stages based on one or more `Ordering` criteria. diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 9695624fbdd..e73c4610fbb 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -645,6 +645,61 @@ export class Sort implements Stage { } } +export type InternalSearchStageOptions = Omit< + firestore.Pipelines.SearchStageOptions, + 'query' +> & { + query?: BooleanExpression; +}; + +/** + * Search stage. + */ +export class Search implements Stage { + name = 'search'; + + constructor(private options: InternalSearchStageOptions) {} + + readonly optionsUtil = new OptionsUtil({ + query: { + serverName: 'query', + }, + limit: { + serverName: 'limit', + }, + retrievalDepth: { + serverName: 'retrieval_depth', + }, + sort: { + serverName: 'sort', + }, + addFields: { + serverName: 'add_fields', + }, + select: { + serverName: 'select', + }, + offset: { + serverName: 'offset', + }, + queryExpansion: { + serverName: 'query_expansion', + }, + }); + + _toProto(serializer: Serializer): api.Pipeline.IStage { + return { + name: this.name, + args: [], + options: this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ), + }; + } +} + /** * Raw stage. */ diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 7bdb4405b17..b78da0bc133 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -171,53 +171,56 @@ import {getTestDb, getTestRoot} from './firestore'; import {Firestore as InternalFirestore} from '../src'; import {ServiceError} from 'google-gax'; +import {documentMatches, score} from '../src/pipelines/expression'; use(chaiAsPromised); const timestampDeltaMS = 3000; +let beginDocCreation = 0; +let endDocCreation = 0; -describe.skipClassic('Pipeline class', () => { - let firestore: Firestore; - let randomCol: CollectionReference; - let beginDocCreation = 0; - let endDocCreation = 0; - - async function testCollectionWithDocs(docs: { +async function testCollectionWithDocs( + collection: CollectionReference, + docs: { [id: string]: DocumentData; - }): Promise> { - beginDocCreation = new Date().valueOf(); - for (const id in docs) { - const ref = randomCol.doc(id); - await ref.set(docs[id]); - } - endDocCreation = new Date().valueOf(); - return randomCol; + }, +): Promise> { + beginDocCreation = new Date().valueOf(); + for (const id in docs) { + const ref = collection.doc(id); + await ref.set(docs[id]); } - - function expectResults(result: PipelineSnapshot, ...docs: string[]): void; - function expectResults( - result: PipelineSnapshot, - ...data: DocumentData[] - ): void; - function expectResults( - result: PipelineSnapshot, - ...data: DocumentData[] | string[] - ): void { - if (data.length > 0) { - if (typeof data[0] === 'string') { - const actualIds = result.results.map(result => result.id); - expect(actualIds).to.deep.equal(data); - } else { - result.results.forEach(r => { - expect(r.data()).to.deep.equal(data.shift()); - }); - } + endDocCreation = new Date().valueOf(); + return collection; +} + +function expectResults(result: PipelineSnapshot, ...docs: string[]): void; +function expectResults(result: PipelineSnapshot, ...data: DocumentData[]): void; +function expectResults( + result: PipelineSnapshot, + ...data: DocumentData[] | string[] +): void { + if (data.length > 0) { + if (typeof data[0] === 'string') { + const actualIds = result.results.map(result => result.id); + expect(actualIds).to.deep.equal(data); } else { - expect(result.results.length).to.equal(data.length); + result.results.forEach(r => { + expect(r.data()).to.deep.equal(data.shift()); + }); } + } else { + expect(result.results.length).to.equal(data.length); } +} + +describe.skipClassic('Pipeline class', () => { + let firestore: Firestore; + let randomCol: CollectionReference; - async function setupBookDocs(): Promise> { + async function setupBookDocs( + collection: CollectionReference, + ): Promise> { const bookDocs: {[id: string]: DocumentData} = { book1: { title: "The Hitchhiker's Guide to the Galaxy", @@ -327,12 +330,12 @@ describe.skipClassic('Pipeline class', () => { embedding: FieldValue.vector([1, 1, 1, 1, 1, 1, 1, 1, 1, 10]), }, }; - return testCollectionWithDocs(bookDocs); + return testCollectionWithDocs(collection, bookDocs); } before(async () => { randomCol = getTestRoot(); - await setupBookDocs(); + await setupBookDocs(randomCol); firestore = randomCol.firestore; }); @@ -5503,6 +5506,601 @@ describe.skipClassic('Pipeline class', () => { }); }); +// Search tests require a collection with an index, so the test setup and tear +// down is managed different from the rest of the Pipeline tests. To accomplish +// this, we break these tests into a separate describe +describe.skipClassic('Pipeline search', () => { + let firestore: Firestore; + let restaurantsCollection: CollectionReference; + + async function setupRestaurantDocs( + collection: CollectionReference, + ): Promise> { + const restaurantDocs: {[id: string]: DocumentData} = { + sunnySideUp: { + name: 'The Sunny Side Up', + description: + 'A cozy neighborhood diner serving classic breakfast favorites all day long, from fluffy pancakes to savory omelets.', + location: new GeoPoint(39.7541, -105.0002), + menu: '

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: 15, + }, + goldenWaffle: { + name: 'The Golden Waffle', + description: + 'Specializing exclusively in Belgian-style waffles. Open daily from 6:00 AM to 11:00 AM.', + location: new GeoPoint(39.7183, -104.9621), + menu: '

Signature Waffles

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

Drinks

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

Appetizers

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

Main Course

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

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: 45, + }, + peakBurgers: { + name: 'Peak Burgers', + description: + 'Casual burger joint focused on locally sourced Colorado beef and hand-cut fries.', + location: new GeoPoint(39.7622, -105.0125), + menu: '

Burgers

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

Sides

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

Tacos ($3.50 each)

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

Beverages

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

Tacos

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

Drinks

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

Fried Chicken

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

Drinks

  • House Margarita - $9
  • Jarritos - $3
', + average_price_per_person: 12, + }, + }; + + // TODO(search) - Migrate this over to IndexTestHelper when search supports the equal filter. + // Remove any restaurant docs not in the expected set - perhaps these were + // set by another dev or test suite. This has potential to cause flakes in another concurrent + // run of these tests, if they have added new test docs. + const collectionSnapshot = await collection.get(); + const expectedDocIds = Object.keys(restaurantDocs); + const deletes = collectionSnapshot.docs + .filter(ds => expectedDocIds.indexOf(ds.id) < 0) + .map(ds => ds.ref.delete()); + await Promise.all(deletes); + + // Add/overwrite all restaurant docs + return testCollectionWithDocs(collection, restaurantDocs); + } + + // Search tests will use restaurant docs + before(async () => { + // TODO(search) - Migrate this over to IndexTestHelper when search supports the equal filter. + // Note: using a static collection of documents for every search test has an inherent risk + // of flakiness. Search requires an index on the collection, which is the reason we use a pre-defined + // collection. We cannot use the IndexTestHelper because that relies on an equality match to the testID + // field. Search currently does not support the equal expression. + firestore = getTestDb(); + restaurantsCollection = firestore.collection('SearchIntegrationTests'); + await setupRestaurantDocs(restaurantsCollection); + firestore = restaurantsCollection.firestore; + }); + + describe('search stage', () => { + describe('DISABLE query expansion', () => { + describe('query', () => { + it('all search features', async () => { + const queryLocation = new GeoPoint(0, 0); + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: and( + documentMatches('waffles'), + field('description').matches('breakfast'), + field('location').geoDistance(queryLocation).lessThan(1000), + field('avgPrice').between(10, 20), + ), + select: [ + field('title'), + field('menu'), + field('description'), + field('location').geoDistance(queryLocation).as('distance'), + ], + addFields: [score().as('searchScore')], + offset: 0, + retrievalDepth: 1000, + limit: 50, + sort: [field('location').geoDistance(queryLocation).ascending()], + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle'); + }); + + it('search full document', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: documentMatches('waffles'), + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle'); + }); + + it('search a specific field', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches('waffles'), + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle'); + }); + + it('geo near query', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('location') + .geoDistance(new GeoPoint(39.6985, -105.024)) + .lessThan(1000 /* m */), + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'solTacos'); + }); + + it('conjunction of text search predicates', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: and( + field('menu').matches('waffles'), + field('description').matches('diner'), + ), + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + }); + + it('conjunction of text search and geo near', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: and( + field('menu').matches('tacos'), + field('location') + .geoDistance(new GeoPoint(39.6985, -105.024)) + .lessThan(10_000 /* meters */), + ), + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'solTacos'); + }); + + it('negate match', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches('-waffles'), + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults( + snapshot, + 'eastSideTacos', + 'solTacos', + 'peakBurgers', + 'mileHighCatch', + 'lotusBlossomThai', + 'sunnySideUp', + ); + }); + + it('rquery search the document with conjunction and disjunction', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: documentMatches('(waffles OR pancakes) AND coffee'), + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + }); + + it('rquery as query param', async () => { + const ppl = firestore.pipeline().collection('restaurants').search({ + query: '(waffles OR pancakes) AND coffee', + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + }); + + it('rquery supports field paths', async () => { + const ppl = firestore.pipeline().collection('restaurants').search({ + query: + 'menu:(waffles OR pancakes) AND description:"breakfast all day"', + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'sunnySideUp'); + }); + + it('conjunction of rquery and expression', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: and( + documentMatches('tacos'), + field('average_price_per_person').between(8, 15), + ), + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'solTacos'); + }); + }); + + describe('addFields', () => { + it('topicality score and snippet', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches('waffles'), + addFields: [ + score().as('searchScore'), + field('menu').snippet('waffles').as('snippet'), + ], + queryEnhancement: 'disabled', + }) + .select('name', 'searchScore', 'snippet'); + + const snapshot = await ppl.execute(); + expect(snapshot.results.length).to.equal(1); + expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); + expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); + expect(snapshot.results[0].get('snippet')?.length).to.be.greaterThan( + 0, + ); + }); + }); + + describe('select', () => { + it('topicality score and snippet', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches('waffles'), + select: [ + field('name'), + 'location', + score().as('searchScore'), + field('menu').snippet('waffles').as('snippet'), + ], + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expect(snapshot.results.length).to.equal(1); + expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); + expect(snapshot.results[0].get('location')).to.equal( + new GeoPoint(39.7183, -104.9621), + ); + expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); + expect(snapshot.results[0].get('snippet')?.length).to.be.greaterThan( + 0, + ); + expect(Object.keys(snapshot.results[0].data()).sort()).to.deep.equal([ + 'location', + 'name', + 'searchScore', + 'snippet', + ]); + }); + }); + + describe('sort', () => { + it('by topicality', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches('tacos'), + sort: score().descending(), + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'eastsideTacos', 'solTacos'); + }); + + it('by distance', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches('tacos'), + sort: field('location') + .geoDistance(new GeoPoint(39.6985, -105.024)) + .ascending(), + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'solTacos', 'eastsideTacos'); + }); + + it('by multiple orderings', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches('tacos OR chicken'), + sort: [ + field('location') + .geoDistance(new GeoPoint(39.6985, -105.024)) + .ascending(), + score().descending(), + ], + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults( + snapshot, + 'solTacos', + 'eastsideTacos', + 'eastsideChicken', + ); + }); + }); + + describe('limit', () => { + it('limits the number of documents returned', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: constant(true), + sort: field('location') + .geoDistance(new GeoPoint(39.6985, -105.024)) + .ascending(), + limit: 5, + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults( + snapshot, + 'solTacos', + 'lotusBlossomThai', + 'goldenWaffle', + ); + }); + + it('limits the number of documents scored', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches( + 'chicken OR tacos OR fish OR waffles', + ), + retrievalDepth: 6, + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults( + snapshot, + 'eastsideChicken', + 'eastsideTacos', + 'solTacos', + 'mileHighCatch', + ); + }); + }); + + describe('offset', () => { + it('skips N documents', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: constant(true), + limit: 2, + offset: 2, + queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'eastsideChicken', 'eastsideTacos'); + }); + }); + }); + + describe('REQUIRE query expansion', () => { + it('search full document', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: documentMatches('waffles'), + queryEnhancement: 'required', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + }); + + it('search a specific field', async () => { + const ppl = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches('waffles'), + queryEnhancement: 'required', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + }); + }); + }); + + describe('snippet', () => { + it('snippet options', async () => { + const ppl1 = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches('waffles'), + addFields: [ + field('menu') + .snippet({ + rquery: 'waffles', + maxSnippetWidth: 10, + }) + .as('snippet'), + ], + queryEnhancement: 'disabled', + }); + + const snapshot1 = await ppl1.execute(); + expect(snapshot1.results.length).to.equal(1); + expect(snapshot1.results[0].get('name')).to.equal('The Golden Waffle'); + expect(snapshot1.results[0].get('snippet')?.length).to.be.greaterThan(0); + + const ppl2 = firestore + .pipeline() + .collection('restaurants') + .search({ + query: field('menu').matches('waffles'), + addFields: [ + field('menu') + .snippet({ + rquery: 'waffles', + maxSnippetWidth: 1000, + }) + .as('snippet'), + ], + queryEnhancement: 'disabled', + }); + + const snapshot2 = await ppl2.execute(); + expect(snapshot2.results.length).to.equal(1); + expect(snapshot2.results[0].get('name')).to.equal('The Golden Waffle'); + expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan(0); + + expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan( + snapshot2.results[0].get('snippet')?.length, + ); + }); + + it('snippet on multiple fields', async () => { + // Get snippet from 1 field + const ppl1 = firestore + .pipeline() + .collection('restaurants') + .search({ + query: documentMatches('waffle'), + addFields: [ + field('menu') + .snippet({ + rquery: 'waffles', + maxSnippetWidth: 2000, + }) + .as('snippet'), + ], + queryEnhancement: 'disabled', + }); + + const snapshot1 = await ppl1.execute(); + expect(snapshot1.results.length).to.equal(1); + expect(snapshot1.results[0].get('name')).to.equal('The Golden Waffle'); + expect(snapshot1.results[0].get('snippet')?.length).to.be.greaterThan(0); + + // Get snippet from 2 fields + const ppl2 = firestore + .pipeline() + .collection('restaurants') + .search({ + query: documentMatches('waffle'), + addFields: [ + concat(field('menu'), field('description')) + .snippet({ + rquery: 'waffles', + maxSnippetWidth: 2000, + }) + .as('snippet'), + ], + queryEnhancement: 'disabled', + }); + + const snapshot2 = await ppl2.execute(); + expect(snapshot2.results.length).to.equal(1); + expect(snapshot2.results[0].get('name')).to.equal('The Golden Waffle'); + expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan(0); + + // Expect snippet from 2 fields to be longer than snippet from one field + expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan( + snapshot2.results[0].get('snippet')?.length, + ); + }); + }); +}); + // This is the Query integration tests from the lite API (no cache support) // with some additional test cases added for more complete coverage. // eslint-disable-next-line no-restricted-properties diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index e58444a2053..a539d4feb73 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -5603,6 +5603,65 @@ declare namespace FirebaseFirestore { */ isType(type: Type): BooleanExpression; + /** + * 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(contant(2.2)), field('tireWidth').lessThanOrEqual(constant(2.4))) + * ``` + * + * @param lowerBound - Lower bound (inclusive) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ + between( + lowerBound: Expression, + upperBound: Expression, + ): BooleanExpression; + + /** + * 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) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ + between(lowerBound: unknown, upperBound: unknown): BooleanExpression; + + /** + * Evaluates to an HTML-formatted text snippet that renders terms matching + * the search query in `bold`. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param rquery Define the search query using the search DTS (TODO(search) link). + */ + snippet(rquery: string): Expression; + + /** + * Evaluates to an HTML-formatted text snippet that renders terms matching + * the search query in `bold`. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param options Define how snippeting behaves. + */ + snippet(options: SnippetOptions): Expression; + // TODO(new-expression): Add new expression method declarations above this line /** * @beta @@ -5831,6 +5890,26 @@ declare namespace FirebaseFirestore { * @returns The name of the field. */ get fieldName(): string; + + /** + * Perform a full-text search on this field. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param rquery Define the search query using the rquery DTS. + */ + matches(rquery: string | Expression): BooleanExpression; + + /** + * Evaluates to the distance in meters between the location specified + * by this field and the query location. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param location - Compute distance to this GeoPoint. + */ + geoDistance(location: GeoPoint | Expression): Expression; + /** * @beta * @internal @@ -11375,6 +11454,174 @@ declare namespace FirebaseFirestore { expression: Expression, type: Type, ): BooleanExpression; + /** + * Perform a full-text search on the specified field. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param searchField Search the specified field. + * @param rquery Define the search query using the search DTS. + */ + export function matches( + searchField: string | Field, + rquery: string | Expression, + ): BooleanExpression; + + /** + * Perform a full-text search on the document. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param rquery Define the search query using the rquery DTS. + */ + export function documentMatches( + rquery: string | Expression, + ): BooleanExpression; + + /** + * Evaluates to the search score that reflects the topicality of the document + * to all of the text predicates (`queryMatch`) + * in the search query. If `SearchOptions.query` is not set or does not contain + * any text predicates, then this topicality score will always be `0`. + * + * @remarks This Expression can only be used within a `Search` stage. + */ + export function score(): Expression; + + /** + * Evaluates to an HTML-formatted text snippet that highlights terms matching + * the search query in `bold`. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param searchField Search the specified field for matching terms. + * @param rquery Define the search query using the search DTS (TODO(search) link). + */ + export function snippet( + searchField: string | Field, + rquery: string, + ): Expression; + + /** + * Evaluates to an HTML-formatted text snippet that highlights terms matching + * the search query in `bold`. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param searchField Search the specified field for matching terms. + * @param options Define the search query using the search DTS (TODO(search) link). + */ + export function snippet( + searchField: string | Field, + options: SnippetOptions, + ): Expression; + + /** + * Evaluates to the distance in meters between the location in the specified + * field and the query location. + * + * @remarks This Expression can only be used within a `Search` stage. + * + * @param fieldName - Specifies the field in the document which contains + * the first GeoPoint for distance computation. + * @param location - Compute distance to this GeoPoint. + */ + export function geoDistance( + fieldName: string | Field, + location: GeoPoint | Expression, + ): Expression; + + /** + * Evaluates if the value in the field specified by `fieldName` is between + * the evaluated values for `lowerBound` (inclusive) and `upperBound` (inclusive). + * + * @example + * ``` + * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + * between('tireWidth', constant(2.2), constant(2.4)) + * + * // This is functionally equivalent to + * and(greaterThanOrEqual('tireWidth', constant(2.2)), lessThanOrEqual('tireWidth', constant(2.4))) + * ``` + * + * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. + * @param lowerBound - Lower bound (inclusive) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ + export function between( + fieldName: string, + lowerBound: Expression, + upperBound: Expression, + ): BooleanExpression; + + /** + * Evaluates if the value in the field specified by `fieldName` is between + * the values for `lowerBound` (inclusive) and `upperBound` (inclusive). + * + * @example + * ``` + * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + * between('tireWidth', 2.2, 2.4) + * + * // This is functionally equivalent to + * and(greaterThanOrEqual('tireWidth', 2.2), lessThanOrEqual('tireWidth', 2.4)) + * ``` + * + * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. + * @param lowerBound - Lower bound (inclusive) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ + export function between( + fieldName: string, + lowerBound: unknown, + upperBound: unknown, + ): BooleanExpression; + + /** + * Evaluates if the result of the specified `expression` is between + * the results of `lowerBound` (inclusive) and `upperBound` (inclusive). + * + * @example + * ``` + * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + * between(field('tireWidth'), constant(2.2), constant(2.4)) + * + * // This is functionally equivalent to + * and(greaterThanOrEqual(field('tireWidth'), constant(2.2)), lessThanOrEqual(field('tireWidth'), constant(2.4))) + * ``` + * + * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. + * @param lowerBound - Lower bound (inclusive) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ + export function between( + expression: Expression, + lowerBound: Expression, + upperBound: Expression, + ): BooleanExpression; + + /** + * Evaluates if the result of the specified `expression` is between + * the `lowerBound` (inclusive) and `upperBound` (inclusive). + * + * @example + * ``` + * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + * between(field('tireWidth'), 2.2, 2.4) + * + * // This is functionally equivalent to + * and(greaterThanOrEqual(field('tireWidth'), 2.2), lessThanOrEqual(field('tireWidth'), 2.4)) + * ``` + * + * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. + * @param lowerBound - Lower bound (inclusive) of the range. + * @param upperBound - Upper bound (inclusive) of the range. + */ + export function between( + expression: Expression, + lowerBound: unknown, + upperBound: unknown, + ): BooleanExpression; // TODO(new-expression): Add new top-level expression function declarations above this line /** @@ -12845,6 +13092,112 @@ declare namespace FirebaseFirestore { orderings: Ordering[]; }; + /** + * Specifies if the `matches` and `snippet` expressions will enhance the user + * provided query to perform matching of synonyms, misspellings, lemmatization, + * stemming. + * + * required - search will fail if the query enhancement times out or if the query + * enhancement is not supported by the project's DRZ compliance + * requirements. + * preferred - search will fall back to the un-enhanced, user provided query, if + * the query enhancement fails. + */ + export type QueryEnhancement = 'disabled' | 'required' | 'preferred'; + + /** + * Options defining how a SearchStage is evaluated. See {@link @firebase/firestore/pipelines#Pipeline.(search)}. + */ + export type SearchStageOptions = StageOptions & { + /** + * 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. + * + * @example + * ``` + * db.pipeline().collection('restaurants').search({ + * query: or( + * documentContainsText("breakfast"), + * field('menu').containsText('waffle AND coffee') + * ) + * }) + * ``` + * + * The query can also be expressed as a string in the Search DSL: + * + * @example + * ``` + * db.pipeline().collection('restaurants').search({ + * query: 'menu:(waffle and coffee) OR breakfast' + * }) + * ``` + */ + query: BooleanExpression | string; + /** + * The maximum number of documents to return from the Search stage. + */ + limit?: number; + /** + * The maximum number of documents for the search stage to score. Documents + * will be processed in the pre-sort order specified by the search index. + */ + retrievalDepth?: number; + /** + * Orderings specify how the input documents are sorted. + * One or more ordering are required. + */ + sort?: Ordering | Ordering[]; + /** + * The fields to add to each document, specified as a {@link @firebase/firestore/pipelines#Selectable}. + */ + addFields?: Selectable[]; + /** + * The fields to keep or add to each document, + * specified as an array of {@link @firebase/firestore/pipelines#Selectable}. + */ + select?: Array; + /** + * The number of documents to skip. + */ + offset?: number; + /** + * Define the query expansion behavior used by full-text search expressions + * in this search stage. + */ + queryEnhancement?: QueryEnhancement; + }; + + /** + * Options defining how a snippet expression is evaluated. + */ + export type SnippetOptions = { + /** + * Define the search query using the search DTS. + */ + rquery: string; + + /** + * The maximum width of the string estimated for a variable width font. The + * unit is tenths of ems. The default is `160`. + */ + maxSnippetWidth?: number; + + /** + * The maximum number of non-contiguous pieces of text in the returned snippet. + * The default is `1`. + */ + maxSnippets?: number; + + /** + * The string to join the pieces. The default value is '\n' + */ + separator?: string; + }; + /** * @beta * Represents a field value within the explain statistics, which can be a primitive type (null, string, number, boolean) From 61e61197026781ac252de61e62c7583b22d7fa4b Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:25:45 -0600 Subject: [PATCH 2/8] Search fixes and cleanup --- .../dev/src/pipelines/pipeline-util.ts | 16 +- .../firestore/dev/src/pipelines/pipelines.ts | 19 +- .../firestore/dev/src/pipelines/stage.ts | 14 +- .../firestore/dev/test/pipelines/pipeline.ts | 249 +----------- .../firestore/dev/test/pipelines/stage.ts | 359 ++++++++++++++++++ handwritten/firestore/types/firestore.d.ts | 26 +- 6 files changed, 419 insertions(+), 264 deletions(-) create mode 100644 handwritten/firestore/dev/test/pipelines/stage.ts diff --git a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts index 5c12257c23f..909da33e7fe 100644 --- a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts +++ b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts @@ -577,6 +577,7 @@ export function isSelectable( export function isOrdering(val: unknown): val is firestore.Pipelines.Ordering { const candidate = val as firestore.Pipelines.Ordering; return ( + val !== undefined && isExpr(candidate.expr) && (candidate.direction === 'ascending' || candidate.direction === 'descending') @@ -721,10 +722,19 @@ export function fieldOrSelectable(value: string | Selectable): Selectable { } } +/** + * @deprecated use selectablesToObject instead + */ export function selectablesToMap( selectables: (firestore.Pipelines.Selectable | string)[], ): Map { - const result = new Map(); + return new Map(Object.entries(selectablesToObject(selectables))); +} + +export function selectablesToObject( + selectables: (firestore.Pipelines.Selectable | string)[], +): Record { + const result: Record = {}; for (const selectable of selectables) { let alias: string; let expression: Expression; @@ -736,11 +746,11 @@ export function selectablesToMap( expression = selectable._expr as unknown as Expression; } - if (result.get(alias) !== undefined) { + if (result[alias] !== undefined) { throw new Error(`Duplicate alias or field '${alias}'`); } - result.set(alias, expression); + result[alias] = expression; } return result; } diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 0a5c01f1a2f..61af8375a1a 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -39,6 +39,7 @@ import { selectablesToMap, toField, vectorToExpr, + selectablesToObject, } from './pipeline-util'; import {DocumentReference} from '../reference/document-reference'; import {PipelineResponse} from '../reference/types'; @@ -1434,13 +1435,25 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * @return A new `Pipeline` object with this stage appended to the stage list. */ search(options: firestore.Pipelines.SearchStageOptions): Pipeline { - const normalizedQuery = isString(options.query) - ? documentMatches(options.query) - : (options.query as BooleanExpression | undefined); + // Convert user land convenience types to internal types + const normalizedQuery: firestore.Pipelines.BooleanExpression = isExpr( + options.query, + ) + ? options.query + : documentMatches(options.query); + const normalizedSelect: Record | undefined = + options.select ? selectablesToObject(options.select) : undefined; + const normalizedAddFields: Record | undefined = + options.addFields ? selectablesToObject(options.addFields) : undefined; + const normalizedSort: firestore.Pipelines.Ordering[] | undefined = + isOrdering(options.sort) ? [options.sort] : options.sort; const internalOptions: InternalSearchStageOptions = { ...options, query: normalizedQuery, + select: normalizedSelect, + addFields: normalizedAddFields, + sort: normalizedSort, }; // Add stage to the pipeline diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index e73c4610fbb..b06b70e310a 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -647,9 +647,12 @@ export class Sort implements Stage { export type InternalSearchStageOptions = Omit< firestore.Pipelines.SearchStageOptions, - 'query' + 'query' | 'sort' | 'select' | 'addFields' > & { - query?: BooleanExpression; + query: firestore.Pipelines.BooleanExpression; + sort?: Array; + select?: Record; + addFields?: Record; }; /** @@ -682,8 +685,11 @@ export class Search implements Stage { offset: { serverName: 'offset', }, - queryExpansion: { - serverName: 'query_expansion', + queryEnhancement: { + serverName: 'query_enhancement', + }, + languageCode: { + serverName: 'language_code', }, }); diff --git a/handwritten/firestore/dev/test/pipelines/pipeline.ts b/handwritten/firestore/dev/test/pipelines/pipeline.ts index e67514a3342..fa48b1c49ff 100644 --- a/handwritten/firestore/dev/test/pipelines/pipeline.ts +++ b/handwritten/firestore/dev/test/pipelines/pipeline.ts @@ -19,14 +19,9 @@ import {expect} from 'chai'; import * as sinon from 'sinon'; import {createInstance, stream} from '../util/helpers'; import {google} from '../../protos/firestore_v1_proto_api'; -import {Timestamp, Pipelines, Firestore} from '../../src'; +import {Timestamp} from '../../src'; import IExecutePipelineRequest = google.firestore.v1.IExecutePipelineRequest; import IExecutePipelineResponse = google.firestore.v1.IExecutePipelineResponse; -import Pipeline = Pipelines.Pipeline; -import field = Pipelines.field; -import sum = Pipelines.sum; -import descending = Pipelines.descending; -import IValue = google.firestore.v1.IValue; const FIRST_CALL = 0; const EXECUTE_PIPELINE_REQUEST = 0; @@ -185,245 +180,3 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { ); }); }); - -describe('stage option serialization', () => { - // Default rawOptions - const rawOptions: Record = { - foo: 'bar1', - }; - // Default expected serialized options - const expectedSerializedOptions: Record = { - foo: { - stringValue: 'bar1', - }, - }; - - const testDefinitions: Array<{ - name: string; - pipeline: (firestore: Firestore) => Pipeline; - stageIndex?: number; - expectedOptions?: Record; - }> = [ - { - name: 'collection stage', - pipeline: firestore => - firestore.pipeline().collection({ - collection: 'foo', - rawOptions, - forceIndex: 'foo-index', - }), - expectedOptions: { - ...expectedSerializedOptions, - force_index: { - stringValue: 'foo-index', - }, - }, - }, - { - name: 'collection group stage', - pipeline: firestore => - firestore.pipeline().collectionGroup({ - collectionId: 'foo', - rawOptions, - forceIndex: 'bar-index', - }), - expectedOptions: { - ...expectedSerializedOptions, - force_index: { - stringValue: 'bar-index', - }, - }, - }, - { - name: 'documents stage', - pipeline: firestore => - firestore.pipeline().documents({ - docs: ['foo/bar'], - rawOptions, - }), - }, - { - name: 'database stage', - pipeline: firestore => - firestore.pipeline().database({ - rawOptions, - }), - }, - { - name: 'distinct stage', - pipeline: firestore => - firestore - .pipeline() - .database() - .distinct({ - groups: ['foo'], - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'findNearest stage', - pipeline: firestore => - firestore - .pipeline() - .database() - .findNearest({ - field: 'foo', - vectorValue: [0], - distanceMeasure: 'euclidean', - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'select stage', - pipeline: firestore => - firestore - .pipeline() - .database() - .select({ - selections: ['foo'], - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'unnest stage', - pipeline: firestore => - firestore - .pipeline() - .database() - .unnest({ - selectable: field('foo'), - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'addFields stage', - pipeline: firestore => - firestore - .pipeline() - .database() - .addFields({ - fields: [field('foo')], - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'aggregate stage', - pipeline: firestore => - firestore - .pipeline() - .database() - .aggregate({ - accumulators: [sum('foo').as('fooSum')], - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'limit stage', - pipeline: firestore => - firestore.pipeline().database().limit({ - limit: 1, - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'offset stage', - pipeline: firestore => - firestore.pipeline().database().offset({ - offset: 1, - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'removeFields stage', - pipeline: firestore => - firestore - .pipeline() - .database() - .removeFields({ - fields: ['foo'], - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'replaceWith stage', - pipeline: firestore => - firestore.pipeline().database().replaceWith({ - map: 'foo', - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'sample stage', - pipeline: firestore => - firestore.pipeline().database().sample({ - documents: 100, - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'sample stage', - pipeline: firestore => - firestore - .pipeline() - .database() - .sort({ - orderings: [descending('foo')], - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'union stage', - pipeline: firestore => - firestore.pipeline().database().union({ - other: firestore.pipeline().database(), - rawOptions, - }), - stageIndex: 1, - }, - { - name: 'where stage', - pipeline: firestore => - firestore - .pipeline() - .database() - .where({ - condition: field('foo').equal(1), - rawOptions, - }), - stageIndex: 1, - }, - ]; - - testDefinitions.forEach(testDefinition => { - it(testDefinition.name, async () => { - const spy = sinon.fake.returns(stream()); - const firestore = await createInstance({ - executePipeline: spy, - }); - - await testDefinition.pipeline(firestore).execute(); - - const expectedOptions = testDefinition.expectedOptions - ? testDefinition.expectedOptions - : expectedSerializedOptions; - - expect( - spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]['structuredPipeline'][ - 'pipeline' - ]['stages'][testDefinition.stageIndex ?? 0]['options'], - ).to.deep.equal(expectedOptions); - }); - }); -}); diff --git a/handwritten/firestore/dev/test/pipelines/stage.ts b/handwritten/firestore/dev/test/pipelines/stage.ts new file mode 100644 index 00000000000..d3b4535574c --- /dev/null +++ b/handwritten/firestore/dev/test/pipelines/stage.ts @@ -0,0 +1,359 @@ +/** + * @license + * 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. + */ + +import {expect} from 'chai'; +import * as sinon from 'sinon'; +import {createInstance, stream} from '../util/helpers'; +import {google} from '../../protos/firestore_v1_proto_api'; +import {Pipelines, Firestore} from '../../src'; +import Pipeline = Pipelines.Pipeline; +import field = Pipelines.field; +import sum = Pipelines.sum; +import descending = Pipelines.descending; +import constant = Pipelines.constant; +import IValue = google.firestore.v1.IValue; + +const FIRST_CALL = 0; +const EXECUTE_PIPELINE_REQUEST = 0; + +describe('stage option serialization', () => { + // Default rawOptions + const rawOptions: Record = { + foo: 'bar1', + }; + // Default expected serialized options + const expectedSerializedOptions: Record = { + foo: { + stringValue: 'bar1', + }, + }; + + const testDefinitions: Array<{ + name: string; + pipeline: (firestore: Firestore) => Pipeline; + stageIndex?: number; + expectedOptions?: Record; + }> = [ + { + name: 'collection stage', + pipeline: firestore => + firestore.pipeline().collection({ + collection: 'foo', + rawOptions, + forceIndex: 'foo-index', + }), + expectedOptions: { + ...expectedSerializedOptions, + force_index: { + stringValue: 'foo-index', + }, + }, + }, + { + name: 'collection group stage', + pipeline: firestore => + firestore.pipeline().collectionGroup({ + collectionId: 'foo', + rawOptions, + forceIndex: 'bar-index', + }), + expectedOptions: { + ...expectedSerializedOptions, + force_index: { + stringValue: 'bar-index', + }, + }, + }, + { + name: 'documents stage', + pipeline: firestore => + firestore.pipeline().documents({ + docs: ['foo/bar'], + rawOptions, + }), + }, + { + name: 'database stage', + pipeline: firestore => + firestore.pipeline().database({ + rawOptions, + }), + }, + { + name: 'distinct stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .distinct({ + groups: ['foo'], + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'findNearest stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .findNearest({ + field: 'foo', + vectorValue: [0], + distanceMeasure: 'euclidean', + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'select stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .select({ + selections: ['foo'], + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'unnest stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .unnest({ + selectable: field('foo'), + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'addFields stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .addFields({ + fields: [field('foo')], + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'aggregate stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .aggregate({ + accumulators: [sum('foo').as('fooSum')], + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'limit stage', + pipeline: firestore => + firestore.pipeline().database().limit({ + limit: 1, + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'offset stage', + pipeline: firestore => + firestore.pipeline().database().offset({ + offset: 1, + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'removeFields stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .removeFields({ + fields: ['foo'], + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'replaceWith stage', + pipeline: firestore => + firestore.pipeline().database().replaceWith({ + map: 'foo', + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'sample stage', + pipeline: firestore => + firestore.pipeline().database().sample({ + documents: 100, + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'sample stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .sort({ + orderings: [descending('foo')], + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'union stage', + pipeline: firestore => + firestore.pipeline().database().union({ + other: firestore.pipeline().database(), + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'where stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .where({ + condition: field('foo').equal(1), + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'search stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .search({ + query: 'foo', + limit: 1, + retrievalDepth: 2, + offset: 3, + queryEnhancement: 'required', + languageCode: 'en-US', + sort: [field('foo').ascending()], + addFields: [constant(true).as('bar')], + select: [field('id')], + rawOptions, + }), + stageIndex: 1, + expectedOptions: { + add_fields: { + mapValue: { + fields: { + bar: { + booleanValue: true, + }, + }, + }, + }, + foo: { + stringValue: 'bar1', + }, + language_code: { + stringValue: 'en-US', + }, + limit: { + integerValue: '1', + }, + offset: { + integerValue: '3', + }, + query: { + functionValue: { + args: [ + { + stringValue: 'foo', + }, + ], + name: 'document_matches', + }, + }, + query_enhancement: { + stringValue: 'required', + }, + retrieval_depth: { + integerValue: '2', + }, + select: { + mapValue: { + fields: { + id: { + fieldReferenceValue: 'id', + }, + }, + }, + }, + sort: { + arrayValue: { + values: [ + { + mapValue: { + fields: { + direction: { + stringValue: 'ascending', + }, + expression: { + fieldReferenceValue: 'foo', + }, + }, + }, + }, + ], + }, + }, + }, + }, + ]; + + testDefinitions.forEach(testDefinition => { + it(testDefinition.name, async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await testDefinition.pipeline(firestore).execute(); + + const expectedOptions = testDefinition.expectedOptions + ? testDefinition.expectedOptions + : expectedSerializedOptions; + + expect( + spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]['structuredPipeline'][ + 'pipeline' + ]['stages'][testDefinition.stageIndex ?? 0]['options'], + ).to.deep.equal(expectedOptions); + }); + }); +}); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index a539d4feb73..a9c382de08a 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -13137,33 +13137,47 @@ declare namespace FirebaseFirestore { * ``` */ query: BooleanExpression | string; + /** - * The maximum number of documents to return from the Search stage. + * The BCP-47 language code of text in the search query, such as, “en-US” or “sr-Latn” */ - limit?: number; + languageCode?: string; + + // TODO(search) add indexPartition after languageCode + /** * The maximum number of documents for the search stage to score. Documents * will be processed in the pre-sort order specified by the search index. */ retrievalDepth?: number; + /** * Orderings specify how the input documents are sorted. * One or more ordering are required. */ sort?: Ordering | Ordering[]; + /** - * The fields to add to each document, specified as a {@link @firebase/firestore/pipelines#Selectable}. + * The number of documents to skip. */ - addFields?: Selectable[]; + offset?: number; + + /** + * The maximum number of documents to return from the Search stage. + */ + limit?: number; + /** * The fields to keep or add to each document, * specified as an array of {@link @firebase/firestore/pipelines#Selectable}. */ select?: Array; + /** - * The number of documents to skip. + * The fields to add to each document, specified as a {@link @firebase/firestore/pipelines#Selectable}. */ - offset?: number; + addFields?: Selectable[]; + /** * Define the query expansion behavior used by full-text search expressions * in this search stage. From 265178ad4ab7b0577ffc7bc70cc3f51d49f252d2 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:43:35 -0600 Subject: [PATCH 3/8] Fixes for score and snippet expression serialization --- .../firestore/dev/src/pipelines/expression.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index dd6cc73452d..0f0be4eb447 100644 --- a/handwritten/firestore/dev/src/pipelines/expression.ts +++ b/handwritten/firestore/dev/src/pipelines/expression.ts @@ -3831,16 +3831,14 @@ export class SnippetExpression extends FunctionExpression { } _toProto(serializer: Serializer): api.IValue { - return { - functionValue: { - ...super._toProto(serializer), - options: this._optionsUtil.getOptionsProto( - serializer, - this._options ?? {}, - {}, - ), - }, - }; + const proto = super._toProto(serializer); + proto.functionValue!.options = this._optionsUtil.getOptionsProto( + serializer, + this._options ?? {}, + {}, + ); + + return proto; } } @@ -10391,7 +10389,7 @@ export function documentMatches( * @remarks This Expression can only be used within a `Search` stage. */ export function score(): Expression { - return new FunctionExpression('search_score', []).asBoolean(); + return new FunctionExpression('score', []).asBoolean(); } /** From e22c5fe46cab938598a3b7dbf69dc43de14611b9 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:29:07 -0600 Subject: [PATCH 4/8] Update handwritten/firestore/dev/src/pipelines/expression.ts Co-authored-by: Daniel La Rocque --- handwritten/firestore/dev/src/pipelines/expression.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index cf1f47f6044..5ecee7cdb2a 100644 --- a/handwritten/firestore/dev/src/pipelines/expression.ts +++ b/handwritten/firestore/dev/src/pipelines/expression.ts @@ -3108,7 +3108,7 @@ export abstract class Expression * the `lowerBound` (inclusive) and `upperBound` (inclusive). * * @example - * ``` + * ```typescript * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 * field('tireWidth').between(constant(2.2), constant(2.4)) * From f53e05004f58ce929e2d47d47d5b902ac87bb6cb Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:29:20 -0600 Subject: [PATCH 5/8] Update handwritten/firestore/dev/src/pipelines/expression.ts Co-authored-by: Daniel La Rocque --- handwritten/firestore/dev/src/pipelines/expression.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index 5ecee7cdb2a..e7d931f43ca 100644 --- a/handwritten/firestore/dev/src/pipelines/expression.ts +++ b/handwritten/firestore/dev/src/pipelines/expression.ts @@ -3116,8 +3116,8 @@ export abstract class Expression * and(field('tireWidth').greaterThanOrEqual(contant(2.2)), field('tireWidth').lessThanOrEqual(constant(2.4))) * ``` * - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. + * @param lowerBound - An `Expression` that evaluates to the lower bound (inclusive) of the range. + * @param upperBound - An `Expression` that evaluates to the upper bound (inclusive) of the range. */ between(lowerBound: Expression, upperBound: Expression): BooleanExpression; From 0e925babf77309c9547915fbd8942419de1caec9 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:12:28 -0400 Subject: [PATCH 6/8] documentation cleanup --- .../firestore/dev/src/pipelines/expression.ts | 10 ++-- handwritten/firestore/types/firestore.d.ts | 57 ++++++++++++++----- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index 0f0be4eb447..f5a5076677b 100644 --- a/handwritten/firestore/dev/src/pipelines/expression.ts +++ b/handwritten/firestore/dev/src/pipelines/expression.ts @@ -3153,7 +3153,7 @@ export abstract class Expression * * @remarks This Expression can only be used within a `Search` stage. * - * @param rquery Define the search query using the search DTS (TODO(search) link). + * @param rquery Define the search query using the search DSL. */ snippet(rquery: string): Expression; @@ -10356,7 +10356,7 @@ export function isType( * @remarks This Expression can only be used within a `Search` stage. * * @param searchField Search the specified field. - * @param rquery Define the search query using the search DTS. + * @param rquery Define the search query using the search DSL. */ export function matches( searchField: string | Field, @@ -10382,7 +10382,7 @@ export function documentMatches( /** * Evaluates to the search score that reflects the topicality of the document - * to all of the text predicates (`queryMatch`) + * to all 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 topicality score will always be `0`. * @@ -10399,7 +10399,7 @@ export function score(): Expression { * @remarks This Expression can only be used within a `Search` stage. * * @param searchField Search the specified field for matching terms. - * @param rquery Define the search query using the search DTS (TODO(search) link). + * @param rquery Define the search query using the search DSL. */ export function snippet( searchField: string | Field, @@ -10413,7 +10413,7 @@ export function snippet( * @remarks This Expression can only be used within a `Search` stage. * * @param searchField Search the specified field for matching terms. - * @param options Define the search query using the search DTS (TODO(search) link). + * @param options Define the search query using the search DSL. */ export function snippet( searchField: string | Field, diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index a9c382de08a..08795f7f5af 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -5648,7 +5648,7 @@ declare namespace FirebaseFirestore { * * @remarks This Expression can only be used within a `Search` stage. * - * @param rquery Define the search query using the search DTS (TODO(search) link). + * @param rquery Define the search query using the search DSL. */ snippet(rquery: string): Expression; @@ -11460,7 +11460,7 @@ declare namespace FirebaseFirestore { * @remarks This Expression can only be used within a `Search` stage. * * @param searchField Search the specified field. - * @param rquery Define the search query using the search DTS. + * @param rquery Define the search query using the search DSL. */ export function matches( searchField: string | Field, @@ -11468,11 +11468,18 @@ declare namespace FirebaseFirestore { ): BooleanExpression; /** - * Perform a full-text search on the document. + * Perform a full-text search on all indexed search fields in the document. * * @remarks This Expression can only be used within a `Search` stage. * - * @param rquery Define the search query using the rquery DTS. + * @example + * ```typescript + * db.pipeline().collection('restaurants').search({ + * query: documentMatches('waffles OR pancakes') + * }) + * ``` + * + * @param rquery Define the search query using the search DSL. */ export function documentMatches( rquery: string | Expression, @@ -11480,10 +11487,18 @@ declare namespace FirebaseFirestore { /** * Evaluates to the search score that reflects the topicality of the document - * to all of the text predicates (`queryMatch`) + * 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 topicality score will always be `0`. * + * @example + * ```typescript + * db.pipeline().collection('restaurants').search({ + * query: 'waffles', + * sort: score().descending() + * }) + * ``` + * * @remarks This Expression can only be used within a `Search` stage. */ export function score(): Expression; @@ -11492,10 +11507,18 @@ declare namespace FirebaseFirestore { * Evaluates to an HTML-formatted text snippet that highlights terms matching * the search query in `bold`. * + * @example + * ```typescript + * db.pipeline().collection('restaurants').search({ + * query: 'waffles', + * addFields: { snippet: snippet('menu', 'waffles') } + * }) + * ``` + * * @remarks This Expression can only be used within a `Search` stage. * * @param searchField Search the specified field for matching terms. - * @param rquery Define the search query using the search DTS (TODO(search) link). + * @param rquery Define the search query using the search DSL. */ export function snippet( searchField: string | Field, @@ -11509,7 +11532,7 @@ declare namespace FirebaseFirestore { * @remarks This Expression can only be used within a `Search` stage. * * @param searchField Search the specified field for matching terms. - * @param options Define the search query using the search DTS (TODO(search) link). + * @param options Define the search query using the search DSL. */ export function snippet( searchField: string | Field, @@ -11520,6 +11543,14 @@ declare namespace FirebaseFirestore { * Evaluates to the distance in meters between the location in the specified * field and the query location. * + * @example + * ```typescript + * db.pipeline().collection('restaurants').search({ + * query: 'waffles', + * sort: geoDistance('location', new GeoPoint(37.0, -122.0)).ascending() + * }) + * ``` + * * @remarks This Expression can only be used within a `Search` stage. * * @param fieldName - Specifies the field in the document which contains @@ -13118,11 +13149,11 @@ declare namespace FirebaseFirestore { * are supported in the Search query. * * @example - * ``` + * ```typescript * db.pipeline().collection('restaurants').search({ * query: or( - * documentContainsText("breakfast"), - * field('menu').containsText('waffle AND coffee') + * documentMatches("breakfast"), + * matches('menu', 'waffle AND coffee') * ) * }) * ``` @@ -13146,8 +13177,8 @@ declare namespace FirebaseFirestore { // TODO(search) add indexPartition after languageCode /** - * The maximum number of documents for the search stage to score. Documents - * will be processed in the pre-sort order specified by the search index. + * The maximum number of documents to retrieve. Documents will be retrieved in the + * pre-sort order specified by the search index. */ retrievalDepth?: number; @@ -13190,7 +13221,7 @@ declare namespace FirebaseFirestore { */ export type SnippetOptions = { /** - * Define the search query using the search DTS. + * Define the search query using the search DSL. */ rquery: string; From 781b5c71b447e72b7e1d21a7c840e3896ed7f437 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:05:56 -0400 Subject: [PATCH 7/8] Trim search API to final API --- .../firestore/dev/src/pipelines/expression.ts | 454 ++++---- .../firestore/dev/src/pipelines/pipelines.ts | 6 +- .../firestore/dev/system-test/pipeline.ts | 981 +++++++++--------- .../firestore/dev/test/pipelines/stage.ts | 60 +- handwritten/firestore/types/firestore.d.ts | 560 +++++----- 5 files changed, 1052 insertions(+), 1009 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index f5a5076677b..c00e6d12082 100644 --- a/handwritten/firestore/dev/src/pipelines/expression.ts +++ b/handwritten/firestore/dev/src/pipelines/expression.ts @@ -3103,84 +3103,84 @@ export abstract class Expression ]).asBoolean(); } - /** - * 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(contant(2.2)), field('tireWidth').lessThanOrEqual(constant(2.4))) - * ``` - * - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ - between(lowerBound: Expression, upperBound: Expression): BooleanExpression; - - /** - * 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) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ - between(lowerBound: unknown, upperBound: unknown): BooleanExpression; - - between(lowerBound: unknown, upperBound: unknown): BooleanExpression { - return new FunctionExpression('between', [ - this, - valueToDefaultExpr(lowerBound), - valueToDefaultExpr(upperBound), - ]).asBoolean(); - } - - /** - * Evaluates to an HTML-formatted text snippet that renders terms matching - * the search query in `bold`. - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param rquery Define the search query using the search DSL. - */ - snippet(rquery: string): Expression; - - /** - * Evaluates to an HTML-formatted text snippet that renders terms matching - * the search query in `bold`. - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param options Define how snippeting behaves. - */ - snippet(options: firestore.Pipelines.SnippetOptions): Expression; - - snippet( - queryOrOptions: string | firestore.Pipelines.SnippetOptions, - ): Expression { - const options: firestore.Pipelines.SnippetOptions = isString(queryOrOptions) - ? {rquery: queryOrOptions} - : queryOrOptions; - const rquery = options.rquery; - const internalOptions = { - maxSnippetWidth: options.maxSnippetWidth, - maxSnippets: options.maxSnippets, - separator: options.separator, - }; - return new SnippetExpression([this, constant(rquery)], internalOptions); - } + // /** + // * 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(contant(2.2)), field('tireWidth').lessThanOrEqual(constant(2.4))) + // * ``` + // * + // * @param lowerBound - Lower bound (inclusive) of the range. + // * @param upperBound - Upper bound (inclusive) of the range. + // */ + // between(lowerBound: Expression, upperBound: Expression): BooleanExpression; + // + // /** + // * 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) of the range. + // * @param upperBound - Upper bound (inclusive) of the range. + // */ + // between(lowerBound: unknown, upperBound: unknown): BooleanExpression; + // + // between(lowerBound: unknown, upperBound: unknown): BooleanExpression { + // return new FunctionExpression('between', [ + // this, + // valueToDefaultExpr(lowerBound), + // valueToDefaultExpr(upperBound), + // ]).asBoolean(); + // } + // + // /** + // * Evaluates to an HTML-formatted text snippet that renders terms matching + // * the search query in `bold`. + // * + // * @remarks This Expression can only be used within a `Search` stage. + // * + // * @param rquery Define the search query using the search DSL. + // */ + // snippet(rquery: string): Expression; + // + // /** + // * Evaluates to an HTML-formatted text snippet that renders terms matching + // * the search query in `bold`. + // * + // * @remarks This Expression can only be used within a `Search` stage. + // * + // * @param options Define how snippeting behaves. + // */ + // snippet(options: firestore.Pipelines.SnippetOptions): Expression; + // + // snippet( + // queryOrOptions: string | firestore.Pipelines.SnippetOptions, + // ): Expression { + // const options: firestore.Pipelines.SnippetOptions = isString(queryOrOptions) + // ? {rquery: queryOrOptions} + // : queryOrOptions; + // const rquery = options.rquery; + // const internalOptions = { + // maxSnippetWidth: options.maxSnippetWidth, + // maxSnippets: options.maxSnippets, + // separator: options.separator, + // }; + // return new SnippetExpression([this, constant(rquery)], internalOptions); + // } // TODO(new-expression): Add new expression method definitions above this line @@ -10350,20 +10350,20 @@ export function isType( return fieldOrExpression(fieldNameOrExpression).isType(type); } -/** - * Perform a full-text search on the specified field. - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param searchField Search the specified field. - * @param rquery Define the search query using the search DSL. - */ -export function matches( - searchField: string | Field, - rquery: string | Expression, -): BooleanExpression { - return toField(searchField).matches(rquery); -} +// /** +// * Perform a full-text search on the specified field. +// * +// * @remarks This Expression can only be used within a `Search` stage. +// * +// * @param searchField Search the specified field. +// * @param rquery Define the search query using the search DSL. +// */ +// export function matches( +// searchField: string | Field, +// rquery: string | Expression, +// ): BooleanExpression { +// return toField(searchField).matches(rquery); +// } /** * Perform a full-text search on the document. @@ -10392,41 +10392,41 @@ export function score(): Expression { return new FunctionExpression('score', []).asBoolean(); } -/** - * Evaluates to an HTML-formatted text snippet that highlights terms matching - * the search query in `bold`. - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param searchField Search the specified field for matching terms. - * @param rquery Define the search query using the search DSL. - */ -export function snippet( - searchField: string | Field, - rquery: string, -): Expression; - -/** - * Evaluates to an HTML-formatted text snippet that highlights terms matching - * the search query in `bold`. - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param searchField Search the specified field for matching terms. - * @param options Define the search query using the search DSL. - */ -export function snippet( - searchField: string | Field, - options: firestore.Pipelines.SnippetOptions, -): Expression; -export function snippet( - field: string | Field, - queryOrOptions: string | firestore.Pipelines.SnippetOptions, -): Expression { - return toField(field).snippet( - isString(queryOrOptions) ? {rquery: queryOrOptions} : queryOrOptions, - ); -} +// /** +// * Evaluates to an HTML-formatted text snippet that highlights terms matching +// * the search query in `bold`. +// * +// * @remarks This Expression can only be used within a `Search` stage. +// * +// * @param searchField Search the specified field for matching terms. +// * @param rquery Define the search query using the search DSL. +// */ +// export function snippet( +// searchField: string | Field, +// rquery: string, +// ): Expression; +// +// /** +// * Evaluates to an HTML-formatted text snippet that highlights terms matching +// * the search query in `bold`. +// * +// * @remarks This Expression can only be used within a `Search` stage. +// * +// * @param searchField Search the specified field for matching terms. +// * @param options Define the search query using the search DSL. +// */ +// export function snippet( +// searchField: string | Field, +// options: firestore.Pipelines.SnippetOptions, +// ): Expression; +// export function snippet( +// field: string | Field, +// queryOrOptions: string | firestore.Pipelines.SnippetOptions, +// ): Expression { +// return toField(field).snippet( +// isString(queryOrOptions) ? {rquery: queryOrOptions} : queryOrOptions, +// ); +// } /** * Evaluates to the distance in meters between the location in the specified @@ -10444,106 +10444,106 @@ export function geoDistance( ): Expression { return toField(fieldName).geoDistance(location); } - -/** - * Evaluates if the value in the field specified by `fieldName` is between - * the evaluated values for `lowerBound` (inclusive) and `upperBound` (inclusive). - * - * @example - * ``` - * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 - * between('tireWidth', constant(2.2), constant(2.4)) - * - * // This is functionally equivalent to - * and(greaterThanOrEqual('tireWidth', constant(2.2)), lessThanOrEqual('tireWidth', constant(2.4))) - * ``` - * - * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ -export function between( - fieldName: string, - lowerBound: Expression, - upperBound: Expression, -): BooleanExpression; - -/** - * Evaluates if the value in the field specified by `fieldName` is between - * the values for `lowerBound` (inclusive) and `upperBound` (inclusive). - * - * @example - * ``` - * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 - * between('tireWidth', 2.2, 2.4) - * - * // This is functionally equivalent to - * and(greaterThanOrEqual('tireWidth', 2.2), lessThanOrEqual('tireWidth', 2.4)) - * ``` - * - * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ -export function between( - fieldName: string, - lowerBound: unknown, - upperBound: unknown, -): BooleanExpression; - -/** - * Evaluates if the result of the specified `expression` is between - * the results of `lowerBound` (inclusive) and `upperBound` (inclusive). - * - * @example - * ``` - * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 - * between(field('tireWidth'), constant(2.2), constant(2.4)) - * - * // This is functionally equivalent to - * and(greaterThanOrEqual(field('tireWidth'), constant(2.2)), lessThanOrEqual(field('tireWidth'), constant(2.4))) - * ``` - * - * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ -export function between( - expression: Expression, - lowerBound: Expression, - upperBound: Expression, -): BooleanExpression; - -/** - * Evaluates if the result of the specified `expression` is between - * the `lowerBound` (inclusive) and `upperBound` (inclusive). - * - * @example - * ``` - * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 - * between(field('tireWidth'), 2.2, 2.4) - * - * // This is functionally equivalent to - * and(greaterThanOrEqual(field('tireWidth'), 2.2), lessThanOrEqual(field('tireWidth'), 2.4)) - * ``` - * - * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ -export function between( - expression: Expression, - lowerBound: unknown, - upperBound: unknown, -): BooleanExpression; - -export function between( - expression: Expression | string, - lowerBound: unknown, - upperBound: unknown, -): BooleanExpression { - return fieldOrExpression(expression).between(lowerBound, upperBound); -} +// +// /** +// * Evaluates if the value in the field specified by `fieldName` is between +// * the evaluated values for `lowerBound` (inclusive) and `upperBound` (inclusive). +// * +// * @example +// * ``` +// * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 +// * between('tireWidth', constant(2.2), constant(2.4)) +// * +// * // This is functionally equivalent to +// * and(greaterThanOrEqual('tireWidth', constant(2.2)), lessThanOrEqual('tireWidth', constant(2.4))) +// * ``` +// * +// * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. +// * @param lowerBound - Lower bound (inclusive) of the range. +// * @param upperBound - Upper bound (inclusive) of the range. +// */ +// export function between( +// fieldName: string, +// lowerBound: Expression, +// upperBound: Expression, +// ): BooleanExpression; +// +// /** +// * Evaluates if the value in the field specified by `fieldName` is between +// * the values for `lowerBound` (inclusive) and `upperBound` (inclusive). +// * +// * @example +// * ``` +// * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 +// * between('tireWidth', 2.2, 2.4) +// * +// * // This is functionally equivalent to +// * and(greaterThanOrEqual('tireWidth', 2.2), lessThanOrEqual('tireWidth', 2.4)) +// * ``` +// * +// * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. +// * @param lowerBound - Lower bound (inclusive) of the range. +// * @param upperBound - Upper bound (inclusive) of the range. +// */ +// export function between( +// fieldName: string, +// lowerBound: unknown, +// upperBound: unknown, +// ): BooleanExpression; +// +// /** +// * Evaluates if the result of the specified `expression` is between +// * the results of `lowerBound` (inclusive) and `upperBound` (inclusive). +// * +// * @example +// * ``` +// * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 +// * between(field('tireWidth'), constant(2.2), constant(2.4)) +// * +// * // This is functionally equivalent to +// * and(greaterThanOrEqual(field('tireWidth'), constant(2.2)), lessThanOrEqual(field('tireWidth'), constant(2.4))) +// * ``` +// * +// * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. +// * @param lowerBound - Lower bound (inclusive) of the range. +// * @param upperBound - Upper bound (inclusive) of the range. +// */ +// export function between( +// expression: Expression, +// lowerBound: Expression, +// upperBound: Expression, +// ): BooleanExpression; +// +// /** +// * Evaluates if the result of the specified `expression` is between +// * the `lowerBound` (inclusive) and `upperBound` (inclusive). +// * +// * @example +// * ``` +// * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 +// * between(field('tireWidth'), 2.2, 2.4) +// * +// * // This is functionally equivalent to +// * and(greaterThanOrEqual(field('tireWidth'), 2.2), lessThanOrEqual(field('tireWidth'), 2.4)) +// * ``` +// * +// * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. +// * @param lowerBound - Lower bound (inclusive) of the range. +// * @param upperBound - Upper bound (inclusive) of the range. +// */ +// export function between( +// expression: Expression, +// lowerBound: unknown, +// upperBound: unknown, +// ): BooleanExpression; +// +// export function between( +// expression: Expression | string, +// lowerBound: unknown, +// upperBound: unknown, +// ): BooleanExpression { +// return fieldOrExpression(expression).between(lowerBound, upperBound); +// } // TODO(new-expression): Add new top-level expression function definitions above this line diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 61af8375a1a..4b183b16935 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -1441,8 +1441,8 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ) ? options.query : documentMatches(options.query); - const normalizedSelect: Record | undefined = - options.select ? selectablesToObject(options.select) : undefined; + // const normalizedSelect: Record | undefined = + // options.select ? selectablesToObject(options.select) : undefined; const normalizedAddFields: Record | undefined = options.addFields ? selectablesToObject(options.addFields) : undefined; const normalizedSort: firestore.Pipelines.Ordering[] | undefined = @@ -1451,7 +1451,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { const internalOptions: InternalSearchStageOptions = { ...options, query: normalizedQuery, - select: normalizedSelect, + // select: normalizedSelect, addFields: normalizedAddFields, sort: normalizedSort, }; diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index b78da0bc133..708f3926fd3 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -5597,506 +5597,537 @@ describe.skipClassic('Pipeline search', () => { return testCollectionWithDocs(collection, restaurantDocs); } - // Search tests will use restaurant docs - before(async () => { - // TODO(search) - Migrate this over to IndexTestHelper when search supports the equal filter. - // Note: using a static collection of documents for every search test has an inherent risk - // of flakiness. Search requires an index on the collection, which is the reason we use a pre-defined - // collection. We cannot use the IndexTestHelper because that relies on an equality match to the testID - // field. Search currently does not support the equal expression. - firestore = getTestDb(); - restaurantsCollection = firestore.collection('SearchIntegrationTests'); - await setupRestaurantDocs(restaurantsCollection); - firestore = restaurantsCollection.firestore; - }); - - describe('search stage', () => { - describe('DISABLE query expansion', () => { - describe('query', () => { - it('all search features', async () => { - const queryLocation = new GeoPoint(0, 0); - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: and( - documentMatches('waffles'), - field('description').matches('breakfast'), - field('location').geoDistance(queryLocation).lessThan(1000), - field('avgPrice').between(10, 20), - ), - select: [ - field('title'), - field('menu'), - field('description'), - field('location').geoDistance(queryLocation).as('distance'), - ], - addFields: [score().as('searchScore')], - offset: 0, - retrievalDepth: 1000, - limit: 50, - sort: [field('location').geoDistance(queryLocation).ascending()], - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'goldenWaffle'); - }); - - it('search full document', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: documentMatches('waffles'), - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'goldenWaffle'); - }); - - it('search a specific field', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches('waffles'), - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'goldenWaffle'); - }); - - it('geo near query', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('location') - .geoDistance(new GeoPoint(39.6985, -105.024)) - .lessThan(1000 /* m */), - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'solTacos'); - }); - - it('conjunction of text search predicates', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: and( - field('menu').matches('waffles'), - field('description').matches('diner'), - ), - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); - }); + describe('text search', () => { + const COLLECTION_NAME = 'TextSearchIntegrationTests'; + + // Search tests will use restaurant docs + before(async () => { + // TODO(search) - Migrate this over to IndexTestHelper when search supports the equal filter. + // Note: using a static collection of documents for every search test has an inherent risk + // of flakiness. Search requires an index on the collection, which is the reason we use a pre-defined + // collection. We cannot use the IndexTestHelper because that relies on an equality match to the testID + // field. Search currently does not support the equal expression. + firestore = getTestDb(); + restaurantsCollection = firestore.collection(COLLECTION_NAME); + await setupRestaurantDocs(restaurantsCollection); + firestore = restaurantsCollection.firestore; + }); + + describe('search stage', () => { + describe('DISABLE query expansion', () => { + describe('query', () => { + // TODO(search) enable with backend support + // it('all search features', async () => { + // const queryLocation = new GeoPoint(0, 0); + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: and( + // documentMatches('waffles'), + // field('description').matches('breakfast'), + // field('location').geoDistance(queryLocation).lessThan(1000), + // field('avgPrice').between(10, 20), + // ), + // select: [ + // field('title'), + // field('menu'), + // field('description'), + // field('location').geoDistance(queryLocation).as('distance'), + // ], + // addFields: [score().as('searchScore')], + // offset: 0, + // retrievalDepth: 1000, + // limit: 50, + // sort: [field('location').geoDistance(queryLocation).ascending()], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'goldenWaffle'); + // }); + + it('search full document', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: documentMatches('waffles'), + // queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle'); + }); - it('conjunction of text search and geo near', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: and( - field('menu').matches('tacos'), - field('location') + // TODO(search) enable with per-field matching + // it('search a specific field', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'goldenWaffle'); + // }); + + // TODO(search) enable with per-field matching and support for AND expression + // it('conjunction of text search predicates', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: and( + // field('menu').matches('waffles'), + // field('description').matches('diner'), + // ), + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + // }); + + it('geo near query', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: field('location') .geoDistance(new GeoPoint(39.6985, -105.024)) - .lessThan(10_000 /* meters */), - ), - queryEnhancement: 'disabled', - }); + .lessThan(1000 /* m */), + // queryEnhancement: 'disabled', + }); - const snapshot = await ppl.execute(); - expectResults(snapshot, 'solTacos'); - }); - - it('negate match', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches('-waffles'), - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults( - snapshot, - 'eastSideTacos', - 'solTacos', - 'peakBurgers', - 'mileHighCatch', - 'lotusBlossomThai', - 'sunnySideUp', - ); - }); - - it('rquery search the document with conjunction and disjunction', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: documentMatches('(waffles OR pancakes) AND coffee'), - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); - }); - - it('rquery as query param', async () => { - const ppl = firestore.pipeline().collection('restaurants').search({ - query: '(waffles OR pancakes) AND coffee', - queryEnhancement: 'disabled', + const snapshot = await ppl.execute(); + expectResults(snapshot, 'solTacos'); }); - const snapshot = await ppl.execute(); - expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); - }); - - it('rquery supports field paths', async () => { - const ppl = firestore.pipeline().collection('restaurants').search({ - query: - 'menu:(waffles OR pancakes) AND description:"breakfast all day"', - queryEnhancement: 'disabled', + // TODO(search) enable with geo+text search indexes + // it('conjunction of text search and geo near', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: and( + // field('menu').matches('tacos'), + // field('location') + // .geoDistance(new GeoPoint(39.6985, -105.024)) + // .lessThan(10_000 /* meters */), + // ), + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'solTacos'); + // }); + + it('negate match', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: documentMatches('coffee -waffles'), + // queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'sunnySideUp'); }); - const snapshot = await ppl.execute(); - expectResults(snapshot, 'sunnySideUp'); - }); - - it('conjunction of rquery and expression', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: and( - documentMatches('tacos'), - field('average_price_per_person').between(8, 15), - ), - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'solTacos'); - }); - }); - - describe('addFields', () => { - it('topicality score and snippet', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches('waffles'), - addFields: [ - score().as('searchScore'), - field('menu').snippet('waffles').as('snippet'), - ], - queryEnhancement: 'disabled', - }) - .select('name', 'searchScore', 'snippet'); - - const snapshot = await ppl.execute(); - expect(snapshot.results.length).to.equal(1); - expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); - expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); - expect(snapshot.results[0].get('snippet')?.length).to.be.greaterThan( - 0, - ); - }); - }); - - describe('select', () => { - it('topicality score and snippet', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches('waffles'), - select: [ - field('name'), - 'location', - score().as('searchScore'), - field('menu').snippet('waffles').as('snippet'), - ], - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expect(snapshot.results.length).to.equal(1); - expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); - expect(snapshot.results[0].get('location')).to.equal( - new GeoPoint(39.7183, -104.9621), - ); - expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); - expect(snapshot.results[0].get('snippet')?.length).to.be.greaterThan( - 0, - ); - expect(Object.keys(snapshot.results[0].data()).sort()).to.deep.equal([ - 'location', - 'name', - 'searchScore', - 'snippet', - ]); - }); - }); - - describe('sort', () => { - it('by topicality', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches('tacos'), - sort: score().descending(), - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'eastsideTacos', 'solTacos'); - }); + // TODO(search) this level of rquery is not yet supported + it.skip('rquery search the document with conjunction and disjunction', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: documentMatches('(waffles OR pancakes) AND coffee'), + // queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + }); - it('by distance', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches('tacos'), - sort: field('location') - .geoDistance(new GeoPoint(39.6985, -105.024)) - .ascending(), - queryEnhancement: 'disabled', - }); + it('rquery as query param', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: 'chicken wings', + // queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'eastsideChicken'); + }); - const snapshot = await ppl.execute(); - expectResults(snapshot, 'solTacos', 'eastsideTacos'); + // TODO(search) enable with advanced rquery support + // it('rquery supports field paths', async () => { + // const ppl = firestore.pipeline().collection(COLLECTION_NAME).search({ + // query: + // 'menu:(waffles OR pancakes) AND description:"breakfast all day"', + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'sunnySideUp'); + // }); + + // TODO(search) enable with support of other expressions in the search stage + // it('conjunction of rquery and expression', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: and( + // documentMatches('tacos'), + // field('average_price_per_person').between(8, 15), + // ), + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'solTacos'); + // }); }); - it('by multiple orderings', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches('tacos OR chicken'), - sort: [ - field('location') - .geoDistance(new GeoPoint(39.6985, -105.024)) - .ascending(), - score().descending(), - ], - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults( - snapshot, - 'solTacos', - 'eastsideTacos', - 'eastsideChicken', - ); - }); - }); + describe('addFields', () => { + it('score', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: documentMatches('waffles'), + addFields: [score().as('searchScore')], + // queryEnhancement: 'disabled', + }) + .select('name', 'searchScore', 'snippet'); - describe('limit', () => { - it('limits the number of documents returned', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: constant(true), - sort: field('location') - .geoDistance(new GeoPoint(39.6985, -105.024)) - .ascending(), - limit: 5, - queryEnhancement: 'disabled', - }); + const snapshot = await ppl.execute(); + expect(snapshot.results.length).to.equal(1); + expect(snapshot.results[0].get('name')).to.equal( + 'The Golden Waffle', + ); + expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); + }); - const snapshot = await ppl.execute(); - expectResults( - snapshot, - 'solTacos', - 'lotusBlossomThai', - 'goldenWaffle', - ); + // TODO(search) enable with backend support + // it('topicality score and snippet', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // addFields: [ + // score().as('searchScore'), + // field('menu').snippet('waffles').as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }) + // .select('name', 'searchScore', 'snippet'); + // + // const snapshot = await ppl.execute(); + // expect(snapshot.results.length).to.equal(1); + // expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); + // expect(snapshot.results[0].get('snippet')?.length).to.be.greaterThan( + // 0, + // ); + // }); }); - it('limits the number of documents scored', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches( - 'chicken OR tacos OR fish OR waffles', - ), - retrievalDepth: 6, - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults( - snapshot, - 'eastsideChicken', - 'eastsideTacos', - 'solTacos', - 'mileHighCatch', - ); + describe('select', () => { + // TODO(search) enable with backend support + // it('topicality score and snippet', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // select: [ + // field('name'), + // 'location', + // score().as('searchScore'), + // field('menu').snippet('waffles').as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expect(snapshot.results.length).to.equal(1); + // expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot.results[0].get('location')).to.equal( + // new GeoPoint(39.7183, -104.9621), + // ); + // expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); + // expect(snapshot.results[0].get('snippet')?.length).to.be.greaterThan( + // 0, + // ); + // expect(Object.keys(snapshot.results[0].data()).sort()).to.deep.equal([ + // 'location', + // 'name', + // 'searchScore', + // 'snippet', + // ]); + // }); }); - }); - describe('offset', () => { - it('skips N documents', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: constant(true), - limit: 2, - offset: 2, - queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'eastsideChicken', 'eastsideTacos'); - }); - }); - }); - - describe('REQUIRE query expansion', () => { - it('search full document', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: documentMatches('waffles'), - queryEnhancement: 'required', + describe('sort', () => { + it('by score', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: documentMatches('tacos'), + sort: score().descending(), + // queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'eastsideTacos', 'solTacos'); }); - const snapshot = await ppl.execute(); - expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); - }); - - it('search a specific field', async () => { - const ppl = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches('waffles'), - queryEnhancement: 'required', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); - }); - }); - }); - - describe('snippet', () => { - it('snippet options', async () => { - const ppl1 = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches('waffles'), - addFields: [ - field('menu') - .snippet({ - rquery: 'waffles', - maxSnippetWidth: 10, - }) - .as('snippet'), - ], - queryEnhancement: 'disabled', - }); - - const snapshot1 = await ppl1.execute(); - expect(snapshot1.results.length).to.equal(1); - expect(snapshot1.results[0].get('name')).to.equal('The Golden Waffle'); - expect(snapshot1.results[0].get('snippet')?.length).to.be.greaterThan(0); - - const ppl2 = firestore - .pipeline() - .collection('restaurants') - .search({ - query: field('menu').matches('waffles'), - addFields: [ - field('menu') - .snippet({ - rquery: 'waffles', - maxSnippetWidth: 1000, - }) - .as('snippet'), - ], - queryEnhancement: 'disabled', + // TODO(search) enable with backend support + // it('by distance', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('tacos'), + // sort: field('location') + // .geoDistance(new GeoPoint(39.6985, -105.024)) + // .ascending(), + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'solTacos', 'eastsideTacos'); + // }); + + // TODO(search) enable with backend support + // it('by multiple orderings', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('tacos OR chicken'), + // sort: [ + // field('location') + // .geoDistance(new GeoPoint(39.6985, -105.024)) + // .ascending(), + // score().descending(), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults( + // snapshot, + // 'solTacos', + // 'eastsideTacos', + // 'eastsideChicken', + // ); + // }); }); - const snapshot2 = await ppl2.execute(); - expect(snapshot2.results.length).to.equal(1); - expect(snapshot2.results[0].get('name')).to.equal('The Golden Waffle'); - expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan(0); - - expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan( - snapshot2.results[0].get('snippet')?.length, - ); - }); - - it('snippet on multiple fields', async () => { - // Get snippet from 1 field - const ppl1 = firestore - .pipeline() - .collection('restaurants') - .search({ - query: documentMatches('waffle'), - addFields: [ - field('menu') - .snippet({ - rquery: 'waffles', - maxSnippetWidth: 2000, - }) - .as('snippet'), - ], - queryEnhancement: 'disabled', + describe('limit', () => { + // it('limits the number of documents returned', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: constant(true), + // sort: field('location') + // .geoDistance(new GeoPoint(39.6985, -105.024)) + // .ascending(), + // limit: 5, + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults( + // snapshot, + // 'solTacos', + // 'lotusBlossomThai', + // 'goldenWaffle', + // ); + // }); + // it('limits the number of documents scored', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches( + // 'chicken OR tacos OR fish OR waffles', + // ), + // retrievalDepth: 6, + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults( + // snapshot, + // 'eastsideChicken', + // 'eastsideTacos', + // 'solTacos', + // 'mileHighCatch', + // ); + // }); }); - const snapshot1 = await ppl1.execute(); - expect(snapshot1.results.length).to.equal(1); - expect(snapshot1.results[0].get('name')).to.equal('The Golden Waffle'); - expect(snapshot1.results[0].get('snippet')?.length).to.be.greaterThan(0); - - // Get snippet from 2 fields - const ppl2 = firestore - .pipeline() - .collection('restaurants') - .search({ - query: documentMatches('waffle'), - addFields: [ - concat(field('menu'), field('description')) - .snippet({ - rquery: 'waffles', - maxSnippetWidth: 2000, - }) - .as('snippet'), - ], - queryEnhancement: 'disabled', + describe('offset', () => { + // it('skips N documents', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: constant(true), + // limit: 2, + // offset: 2, + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'eastsideChicken', 'eastsideTacos'); + // }); }); + }); - const snapshot2 = await ppl2.execute(); - expect(snapshot2.results.length).to.equal(1); - expect(snapshot2.results[0].get('name')).to.equal('The Golden Waffle'); - expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan(0); - - // Expect snippet from 2 fields to be longer than snippet from one field - expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan( - snapshot2.results[0].get('snippet')?.length, - ); + // TODO(search) add these tests when query enhancement is supported + describe.skip('REQUIRE query expansion', () => { + // it('search full document', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: documentMatches('waffles'), + // queryEnhancement: 'required', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + // }); + // + // it('search a specific field', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // queryEnhancement: 'required', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + // }); + }); + }); + + // TODO(search) add these tests when snippet expression is supported + describe.skip('snippet', () => { + // it('snippet options', async () => { + // const ppl1 = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // addFields: [ + // field('menu') + // .snippet({ + // rquery: 'waffles', + // maxSnippetWidth: 10, + // }) + // .as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot1 = await ppl1.execute(); + // expect(snapshot1.results.length).to.equal(1); + // expect(snapshot1.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot1.results[0].get('snippet')?.length).to.be.greaterThan(0); + // + // const ppl2 = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // addFields: [ + // field('menu') + // .snippet({ + // rquery: 'waffles', + // maxSnippetWidth: 1000, + // }) + // .as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot2 = await ppl2.execute(); + // expect(snapshot2.results.length).to.equal(1); + // expect(snapshot2.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan(0); + // + // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan( + // snapshot2.results[0].get('snippet')?.length, + // ); + // }); + // + // it('snippet on multiple fields', async () => { + // // Get snippet from 1 field + // const ppl1 = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: documentMatches('waffle'), + // addFields: [ + // field('menu') + // .snippet({ + // rquery: 'waffles', + // maxSnippetWidth: 2000, + // }) + // .as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot1 = await ppl1.execute(); + // expect(snapshot1.results.length).to.equal(1); + // expect(snapshot1.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot1.results[0].get('snippet')?.length).to.be.greaterThan(0); + // + // // Get snippet from 2 fields + // const ppl2 = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: documentMatches('waffle'), + // addFields: [ + // concat(field('menu'), field('description')) + // .snippet({ + // rquery: 'waffles', + // maxSnippetWidth: 2000, + // }) + // .as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot2 = await ppl2.execute(); + // expect(snapshot2.results.length).to.equal(1); + // expect(snapshot2.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan(0); + // + // // Expect snippet from 2 fields to be longer than snippet from one field + // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan( + // snapshot2.results[0].get('snippet')?.length, + // ); + // }); + // }); }); }); }); diff --git a/handwritten/firestore/dev/test/pipelines/stage.ts b/handwritten/firestore/dev/test/pipelines/stage.ts index d3b4535574c..7c2f7ac225a 100644 --- a/handwritten/firestore/dev/test/pipelines/stage.ts +++ b/handwritten/firestore/dev/test/pipelines/stage.ts @@ -256,14 +256,14 @@ describe('stage option serialization', () => { .database() .search({ query: 'foo', - limit: 1, - retrievalDepth: 2, - offset: 3, - queryEnhancement: 'required', - languageCode: 'en-US', + // limit: 1, + // retrievalDepth: 2, + // offset: 3, + // queryEnhancement: 'required', + // languageCode: 'en-US', sort: [field('foo').ascending()], addFields: [constant(true).as('bar')], - select: [field('id')], + // select: [field('id')], rawOptions, }), stageIndex: 1, @@ -280,15 +280,15 @@ describe('stage option serialization', () => { foo: { stringValue: 'bar1', }, - language_code: { - stringValue: 'en-US', - }, - limit: { - integerValue: '1', - }, - offset: { - integerValue: '3', - }, + // language_code: { + // stringValue: 'en-US', + // }, + // limit: { + // integerValue: '1', + // }, + // offset: { + // integerValue: '3', + // }, query: { functionValue: { args: [ @@ -299,21 +299,21 @@ describe('stage option serialization', () => { name: 'document_matches', }, }, - query_enhancement: { - stringValue: 'required', - }, - retrieval_depth: { - integerValue: '2', - }, - select: { - mapValue: { - fields: { - id: { - fieldReferenceValue: 'id', - }, - }, - }, - }, + // query_enhancement: { + // stringValue: 'required', + // }, + // retrieval_depth: { + // integerValue: '2', + // }, + // select: { + // mapValue: { + // fields: { + // id: { + // fieldReferenceValue: 'id', + // }, + // }, + // }, + // }, sort: { arrayValue: { values: [ diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 08795f7f5af..9cc4fd6f20a 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -5603,64 +5603,66 @@ declare namespace FirebaseFirestore { */ isType(type: Type): BooleanExpression; - /** - * 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(contant(2.2)), field('tireWidth').lessThanOrEqual(constant(2.4))) - * ``` - * - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ - between( - lowerBound: Expression, - upperBound: Expression, - ): BooleanExpression; - - /** - * 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) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ - between(lowerBound: unknown, upperBound: unknown): BooleanExpression; - - /** - * Evaluates to an HTML-formatted text snippet that renders terms matching - * the search query in `bold`. - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param rquery Define the search query using the search DSL. - */ - snippet(rquery: string): Expression; - - /** - * Evaluates to an HTML-formatted text snippet that renders terms matching - * the search query in `bold`. - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param options Define how snippeting behaves. - */ - snippet(options: SnippetOptions): Expression; + // TODO(search) enable with backend support + // /** + // * 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(contant(2.2)), field('tireWidth').lessThanOrEqual(constant(2.4))) + // * ``` + // * + // * @param lowerBound - Lower bound (inclusive) of the range. + // * @param upperBound - Upper bound (inclusive) of the range. + // */ + // between( + // lowerBound: Expression, + // upperBound: Expression, + // ): BooleanExpression; + // + // /** + // * 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) of the range. + // * @param upperBound - Upper bound (inclusive) of the range. + // */ + // between(lowerBound: unknown, upperBound: unknown): BooleanExpression; + + // TODO(search) enable with backend support + // /** + // * Evaluates to an HTML-formatted text snippet that renders terms matching + // * the search query in `bold`. + // * + // * @remarks This Expression can only be used within a `Search` stage. + // * + // * @param rquery Define the search query using the search DSL. + // */ + // snippet(rquery: string): Expression; + // + // /** + // * Evaluates to an HTML-formatted text snippet that renders terms matching + // * the search query in `bold`. + // * + // * @remarks This Expression can only be used within a `Search` stage. + // * + // * @param options Define how snippeting behaves. + // */ + // snippet(options: SnippetOptions): Expression; // TODO(new-expression): Add new expression method declarations above this line /** @@ -5891,14 +5893,15 @@ declare namespace FirebaseFirestore { */ get fieldName(): string; - /** - * Perform a full-text search on this field. - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param rquery Define the search query using the rquery DTS. - */ - matches(rquery: string | Expression): BooleanExpression; + // TODO(search) enable with backend support + // /** + // * Perform a full-text search on this field. + // * + // * @remarks This Expression can only be used within a `Search` stage. + // * + // * @param rquery Define the search query using the rquery DTS. + // */ + // matches(rquery: string | Expression): BooleanExpression; /** * Evaluates to the distance in meters between the location specified @@ -11454,18 +11457,20 @@ declare namespace FirebaseFirestore { expression: Expression, type: Type, ): BooleanExpression; - /** - * Perform a full-text search on the specified field. - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param searchField Search the specified field. - * @param rquery Define the search query using the search DSL. - */ - export function matches( - searchField: string | Field, - rquery: string | Expression, - ): BooleanExpression; + + // TODO(search) enable with backend support + // /** + // * Perform a full-text search on the specified field. + // * + // * @remarks This Expression can only be used within a `Search` stage. + // * + // * @param searchField Search the specified field. + // * @param rquery Define the search query using the search DSL. + // */ + // export function matches( + // searchField: string | Field, + // rquery: string | Expression, + // ): BooleanExpression; /** * Perform a full-text search on all indexed search fields in the document. @@ -11503,41 +11508,42 @@ declare namespace FirebaseFirestore { */ export function score(): Expression; - /** - * Evaluates to an HTML-formatted text snippet that highlights terms matching - * the search query in `bold`. - * - * @example - * ```typescript - * db.pipeline().collection('restaurants').search({ - * query: 'waffles', - * addFields: { snippet: snippet('menu', 'waffles') } - * }) - * ``` - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param searchField Search the specified field for matching terms. - * @param rquery Define the search query using the search DSL. - */ - export function snippet( - searchField: string | Field, - rquery: string, - ): Expression; - - /** - * Evaluates to an HTML-formatted text snippet that highlights terms matching - * the search query in `bold`. - * - * @remarks This Expression can only be used within a `Search` stage. - * - * @param searchField Search the specified field for matching terms. - * @param options Define the search query using the search DSL. - */ - export function snippet( - searchField: string | Field, - options: SnippetOptions, - ): Expression; + // TODO(search) enable with backend support + // /** + // * Evaluates to an HTML-formatted text snippet that highlights terms matching + // * the search query in `bold`. + // * + // * @example + // * ```typescript + // * db.pipeline().collection('restaurants').search({ + // * query: 'waffles', + // * addFields: { snippet: snippet('menu', 'waffles') } + // * }) + // * ``` + // * + // * @remarks This Expression can only be used within a `Search` stage. + // * + // * @param searchField Search the specified field for matching terms. + // * @param rquery Define the search query using the search DSL. + // */ + // export function snippet( + // searchField: string | Field, + // rquery: string, + // ): Expression; + // + // /** + // * Evaluates to an HTML-formatted text snippet that highlights terms matching + // * the search query in `bold`. + // * + // * @remarks This Expression can only be used within a `Search` stage. + // * + // * @param searchField Search the specified field for matching terms. + // * @param options Define the search query using the search DSL. + // */ + // export function snippet( + // searchField: string | Field, + // options: SnippetOptions, + // ): Expression; /** * Evaluates to the distance in meters between the location in the specified @@ -11562,97 +11568,98 @@ declare namespace FirebaseFirestore { location: GeoPoint | Expression, ): Expression; - /** - * Evaluates if the value in the field specified by `fieldName` is between - * the evaluated values for `lowerBound` (inclusive) and `upperBound` (inclusive). - * - * @example - * ``` - * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 - * between('tireWidth', constant(2.2), constant(2.4)) - * - * // This is functionally equivalent to - * and(greaterThanOrEqual('tireWidth', constant(2.2)), lessThanOrEqual('tireWidth', constant(2.4))) - * ``` - * - * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ - export function between( - fieldName: string, - lowerBound: Expression, - upperBound: Expression, - ): BooleanExpression; - - /** - * Evaluates if the value in the field specified by `fieldName` is between - * the values for `lowerBound` (inclusive) and `upperBound` (inclusive). - * - * @example - * ``` - * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 - * between('tireWidth', 2.2, 2.4) - * - * // This is functionally equivalent to - * and(greaterThanOrEqual('tireWidth', 2.2), lessThanOrEqual('tireWidth', 2.4)) - * ``` - * - * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ - export function between( - fieldName: string, - lowerBound: unknown, - upperBound: unknown, - ): BooleanExpression; - - /** - * Evaluates if the result of the specified `expression` is between - * the results of `lowerBound` (inclusive) and `upperBound` (inclusive). - * - * @example - * ``` - * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 - * between(field('tireWidth'), constant(2.2), constant(2.4)) - * - * // This is functionally equivalent to - * and(greaterThanOrEqual(field('tireWidth'), constant(2.2)), lessThanOrEqual(field('tireWidth'), constant(2.4))) - * ``` - * - * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ - export function between( - expression: Expression, - lowerBound: Expression, - upperBound: Expression, - ): BooleanExpression; - - /** - * Evaluates if the result of the specified `expression` is between - * the `lowerBound` (inclusive) and `upperBound` (inclusive). - * - * @example - * ``` - * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 - * between(field('tireWidth'), 2.2, 2.4) - * - * // This is functionally equivalent to - * and(greaterThanOrEqual(field('tireWidth'), 2.2), lessThanOrEqual(field('tireWidth'), 2.4)) - * ``` - * - * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. - * @param lowerBound - Lower bound (inclusive) of the range. - * @param upperBound - Upper bound (inclusive) of the range. - */ - export function between( - expression: Expression, - lowerBound: unknown, - upperBound: unknown, - ): BooleanExpression; + // TODO(search) enable when supported by the backend + // /** + // * Evaluates if the value in the field specified by `fieldName` is between + // * the evaluated values for `lowerBound` (inclusive) and `upperBound` (inclusive). + // * + // * @example + // * ``` + // * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + // * between('tireWidth', constant(2.2), constant(2.4)) + // * + // * // This is functionally equivalent to + // * and(greaterThanOrEqual('tireWidth', constant(2.2)), lessThanOrEqual('tireWidth', constant(2.4))) + // * ``` + // * + // * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. + // * @param lowerBound - Lower bound (inclusive) of the range. + // * @param upperBound - Upper bound (inclusive) of the range. + // */ + // export function between( + // fieldName: string, + // lowerBound: Expression, + // upperBound: Expression, + // ): BooleanExpression; + // + // /** + // * Evaluates if the value in the field specified by `fieldName` is between + // * the values for `lowerBound` (inclusive) and `upperBound` (inclusive). + // * + // * @example + // * ``` + // * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + // * between('tireWidth', 2.2, 2.4) + // * + // * // This is functionally equivalent to + // * and(greaterThanOrEqual('tireWidth', 2.2), lessThanOrEqual('tireWidth', 2.4)) + // * ``` + // * + // * @param fieldName - Evaluate if the value stored in this field is between the lower and upper bounds. + // * @param lowerBound - Lower bound (inclusive) of the range. + // * @param upperBound - Upper bound (inclusive) of the range. + // */ + // export function between( + // fieldName: string, + // lowerBound: unknown, + // upperBound: unknown, + // ): BooleanExpression; + // + // /** + // * Evaluates if the result of the specified `expression` is between + // * the results of `lowerBound` (inclusive) and `upperBound` (inclusive). + // * + // * @example + // * ``` + // * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + // * between(field('tireWidth'), constant(2.2), constant(2.4)) + // * + // * // This is functionally equivalent to + // * and(greaterThanOrEqual(field('tireWidth'), constant(2.2)), lessThanOrEqual(field('tireWidth'), constant(2.4))) + // * ``` + // * + // * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. + // * @param lowerBound - Lower bound (inclusive) of the range. + // * @param upperBound - Upper bound (inclusive) of the range. + // */ + // export function between( + // expression: Expression, + // lowerBound: Expression, + // upperBound: Expression, + // ): BooleanExpression; + // + // /** + // * Evaluates if the result of the specified `expression` is between + // * the `lowerBound` (inclusive) and `upperBound` (inclusive). + // * + // * @example + // * ``` + // * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + // * between(field('tireWidth'), 2.2, 2.4) + // * + // * // This is functionally equivalent to + // * and(greaterThanOrEqual(field('tireWidth'), 2.2), lessThanOrEqual(field('tireWidth'), 2.4)) + // * ``` + // * + // * @param expression - Evaluate if the result of this expression is between the lower and upper bounds. + // * @param lowerBound - Lower bound (inclusive) of the range. + // * @param upperBound - Upper bound (inclusive) of the range. + // */ + // export function between( + // expression: Expression, + // lowerBound: unknown, + // upperBound: unknown, + // ): BooleanExpression; // TODO(new-expression): Add new top-level expression function declarations above this line /** @@ -13123,18 +13130,19 @@ declare namespace FirebaseFirestore { orderings: Ordering[]; }; - /** - * Specifies if the `matches` and `snippet` expressions will enhance the user - * provided query to perform matching of synonyms, misspellings, lemmatization, - * stemming. - * - * required - search will fail if the query enhancement times out or if the query - * enhancement is not supported by the project's DRZ compliance - * requirements. - * preferred - search will fall back to the un-enhanced, user provided query, if - * the query enhancement fails. - */ - export type QueryEnhancement = 'disabled' | 'required' | 'preferred'; + ///** + // * Specifies if the `matches` and `snippet` expressions will enhance the user + // * provided query to perform matching of synonyms, misspellings, lemmatization, + // * stemming. + // * + // * required - search will fail if the query enhancement times out or if the query + // * enhancement is not supported by the project's DRZ compliance + // * requirements. + // * preferred - search will fall back to the un-enhanced, user provided query, if + // * the query enhancement fails. + // */ + // TODO(search) enable with backend support + // export type QueryEnhancement = 'disabled' | 'required' | 'preferred'; /** * Options defining how a SearchStage is evaluated. See {@link @firebase/firestore/pipelines#Pipeline.(search)}. @@ -13151,10 +13159,7 @@ declare namespace FirebaseFirestore { * @example * ```typescript * db.pipeline().collection('restaurants').search({ - * query: or( - * documentMatches("breakfast"), - * matches('menu', 'waffle AND coffee') - * ) + * query: documentMatches("breakfast") * }) * ``` * @@ -13163,24 +13168,26 @@ declare namespace FirebaseFirestore { * @example * ``` * db.pipeline().collection('restaurants').search({ - * query: 'menu:(waffle and coffee) OR breakfast' + * query: 'breakfast' * }) * ``` */ query: BooleanExpression | string; - /** - * The BCP-47 language code of text in the search query, such as, “en-US” or “sr-Latn” - */ - languageCode?: string; + ///** + // * The BCP-47 language code of text in the search query, such as, “en-US” or “sr-Latn” + // */ + // TODO(search) enable with backend support + //languageCode?: string; // TODO(search) add indexPartition after languageCode - /** - * The maximum number of documents to retrieve. Documents will be retrieved in the - * pre-sort order specified by the search index. - */ - retrievalDepth?: number; + ///** + // * The maximum number of documents to retrieve. Documents will be retrieved in the + // * pre-sort order specified by the search index. + // */ + // TODO(search) enable with backend support + //retrievalDepth?: number; /** * Orderings specify how the input documents are sorted. @@ -13188,60 +13195,65 @@ declare namespace FirebaseFirestore { */ sort?: Ordering | Ordering[]; - /** - * The number of documents to skip. - */ - offset?: number; + // /** + // * The number of documents to skip. + // */ + // TODO(search) enable with backend support + // offset?: number; - /** - * The maximum number of documents to return from the Search stage. - */ - limit?: number; + // /** + // * The maximum number of documents to return from the Search stage. + // */ + // TODO(search) enable with backend support + // limit?: number; - /** - * The fields to keep or add to each document, - * specified as an array of {@link @firebase/firestore/pipelines#Selectable}. - */ - select?: Array; + // /** + // * The fields to keep or add to each document, + // * specified as an array of {@link @firebase/firestore/pipelines#Selectable}. + // */ + // TODO(search) enable with backend support + // select?: Array; /** * The fields to add to each document, specified as a {@link @firebase/firestore/pipelines#Selectable}. */ addFields?: Selectable[]; - /** - * Define the query expansion behavior used by full-text search expressions - * in this search stage. - */ - queryEnhancement?: QueryEnhancement; + // /** + // * Define the query expansion behavior used by full-text search expressions + // * in this search stage. + // */ + // TODO(search) enable with backend support + // queryEnhancement?: QueryEnhancement; }; - /** - * Options defining how a snippet expression is evaluated. - */ - export type SnippetOptions = { - /** - * Define the search query using the search DSL. - */ - rquery: string; - - /** - * The maximum width of the string estimated for a variable width font. The - * unit is tenths of ems. The default is `160`. - */ - maxSnippetWidth?: number; - - /** - * The maximum number of non-contiguous pieces of text in the returned snippet. - * The default is `1`. - */ - maxSnippets?: number; - - /** - * The string to join the pieces. The default value is '\n' - */ - separator?: string; - }; + // TODO(search) enable with backend support + // /** + // * Options defining how a snippet expression is evaluated. + // */ + // export type SnippetOptions = { + // /** + // * Define the search query using the search DSL. + // */ + // rquery: string; + // + // /** + // * The maximum width of the string estimated for a variable width font. The + // * unit is tenths of ems. The default is `160`. + // */ + // maxSnippetWidth?: number; + // + // /** + // * The maximum number of non-contiguous pieces of text in the returned snippet. + // * The default is `1`. + // */ + // maxSnippets?: number; + // + // /** + // * The string to join the pieces. The default value is '\n' + // */ + // separator?: string; + // }; /** * @beta From e6f646b90901daf2ae445cb0d671b5852a6d7b3b Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:10:22 -0400 Subject: [PATCH 8/8] format --- .../firestore/dev/system-test/pipeline.ts | 965 +++++++++--------- 1 file changed, 479 insertions(+), 486 deletions(-) diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 708f3926fd3..91fd7b46ed5 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -5597,539 +5597,532 @@ describe.skipClassic('Pipeline search', () => { return testCollectionWithDocs(collection, restaurantDocs); } - describe('text search', () => { - const COLLECTION_NAME = 'TextSearchIntegrationTests'; - - // Search tests will use restaurant docs - before(async () => { - // TODO(search) - Migrate this over to IndexTestHelper when search supports the equal filter. - // Note: using a static collection of documents for every search test has an inherent risk - // of flakiness. Search requires an index on the collection, which is the reason we use a pre-defined - // collection. We cannot use the IndexTestHelper because that relies on an equality match to the testID - // field. Search currently does not support the equal expression. - firestore = getTestDb(); - restaurantsCollection = firestore.collection(COLLECTION_NAME); - await setupRestaurantDocs(restaurantsCollection); - firestore = restaurantsCollection.firestore; - }); - - describe('search stage', () => { - describe('DISABLE query expansion', () => { - describe('query', () => { - // TODO(search) enable with backend support - // it('all search features', async () => { - // const queryLocation = new GeoPoint(0, 0); - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: and( - // documentMatches('waffles'), - // field('description').matches('breakfast'), - // field('location').geoDistance(queryLocation).lessThan(1000), - // field('avgPrice').between(10, 20), - // ), - // select: [ - // field('title'), - // field('menu'), - // field('description'), - // field('location').geoDistance(queryLocation).as('distance'), - // ], - // addFields: [score().as('searchScore')], - // offset: 0, - // retrievalDepth: 1000, - // limit: 50, - // sort: [field('location').geoDistance(queryLocation).ascending()], - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults(snapshot, 'goldenWaffle'); - // }); - - it('search full document', async () => { - const ppl = firestore - .pipeline() - .collection(COLLECTION_NAME) - .search({ - query: documentMatches('waffles'), - // queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'goldenWaffle'); - }); + const COLLECTION_NAME = 'TextSearchIntegrationTests'; - // TODO(search) enable with per-field matching - // it('search a specific field', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: field('menu').matches('waffles'), - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults(snapshot, 'goldenWaffle'); - // }); - - // TODO(search) enable with per-field matching and support for AND expression - // it('conjunction of text search predicates', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: and( - // field('menu').matches('waffles'), - // field('description').matches('diner'), - // ), - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); - // }); - - it('geo near query', async () => { - const ppl = firestore - .pipeline() - .collection(COLLECTION_NAME) - .search({ - query: field('location') - .geoDistance(new GeoPoint(39.6985, -105.024)) - .lessThan(1000 /* m */), - // queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'solTacos'); - }); + // Search tests will use restaurant docs + before(async () => { + // TODO(search) - Migrate this over to IndexTestHelper when search supports the equal filter. + // Note: using a static collection of documents for every search test has an inherent risk + // of flakiness. Search requires an index on the collection, which is the reason we use a pre-defined + // collection. We cannot use the IndexTestHelper because that relies on an equality match to the testID + // field. Search currently does not support the equal expression. + firestore = getTestDb(); + restaurantsCollection = firestore.collection(COLLECTION_NAME); + await setupRestaurantDocs(restaurantsCollection); + firestore = restaurantsCollection.firestore; + }); - // TODO(search) enable with geo+text search indexes - // it('conjunction of text search and geo near', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: and( - // field('menu').matches('tacos'), - // field('location') - // .geoDistance(new GeoPoint(39.6985, -105.024)) - // .lessThan(10_000 /* meters */), - // ), - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults(snapshot, 'solTacos'); - // }); - - it('negate match', async () => { - const ppl = firestore - .pipeline() - .collection(COLLECTION_NAME) - .search({ - query: documentMatches('coffee -waffles'), - // queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'sunnySideUp'); - }); + describe('search stage', () => { + describe('DISABLE query expansion', () => { + describe('query', () => { + // TODO(search) enable with backend support + // it('all search features', async () => { + // const queryLocation = new GeoPoint(0, 0); + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: and( + // documentMatches('waffles'), + // field('description').matches('breakfast'), + // field('location').geoDistance(queryLocation).lessThan(1000), + // field('avgPrice').between(10, 20), + // ), + // select: [ + // field('title'), + // field('menu'), + // field('description'), + // field('location').geoDistance(queryLocation).as('distance'), + // ], + // addFields: [score().as('searchScore')], + // offset: 0, + // retrievalDepth: 1000, + // limit: 50, + // sort: [field('location').geoDistance(queryLocation).ascending()], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'goldenWaffle'); + // }); - // TODO(search) this level of rquery is not yet supported - it.skip('rquery search the document with conjunction and disjunction', async () => { - const ppl = firestore - .pipeline() - .collection(COLLECTION_NAME) - .search({ - query: documentMatches('(waffles OR pancakes) AND coffee'), - // queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); - }); + it('search full document', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: documentMatches('waffles'), + // queryEnhancement: 'disabled', + }); - it('rquery as query param', async () => { - const ppl = firestore - .pipeline() - .collection(COLLECTION_NAME) - .search({ - query: 'chicken wings', - // queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'eastsideChicken'); - }); + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle'); + }); + + // TODO(search) enable with per-field matching + // it('search a specific field', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'goldenWaffle'); + // }); + + // TODO(search) enable with per-field matching and support for AND expression + // it('conjunction of text search predicates', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: and( + // field('menu').matches('waffles'), + // field('description').matches('diner'), + // ), + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + // }); - // TODO(search) enable with advanced rquery support - // it('rquery supports field paths', async () => { - // const ppl = firestore.pipeline().collection(COLLECTION_NAME).search({ - // query: - // 'menu:(waffles OR pancakes) AND description:"breakfast all day"', - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults(snapshot, 'sunnySideUp'); - // }); - - // TODO(search) enable with support of other expressions in the search stage - // it('conjunction of rquery and expression', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: and( - // documentMatches('tacos'), - // field('average_price_per_person').between(8, 15), - // ), - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults(snapshot, 'solTacos'); - // }); + it('geo near query', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: field('location') + .geoDistance(new GeoPoint(39.6985, -105.024)) + .lessThan(1000 /* m */), + // queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'solTacos'); }); - describe('addFields', () => { - it('score', async () => { - const ppl = firestore - .pipeline() - .collection(COLLECTION_NAME) - .search({ - query: documentMatches('waffles'), - addFields: [score().as('searchScore')], - // queryEnhancement: 'disabled', - }) - .select('name', 'searchScore', 'snippet'); - - const snapshot = await ppl.execute(); - expect(snapshot.results.length).to.equal(1); - expect(snapshot.results[0].get('name')).to.equal( - 'The Golden Waffle', - ); - expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); - }); + // TODO(search) enable with geo+text search indexes + // it('conjunction of text search and geo near', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: and( + // field('menu').matches('tacos'), + // field('location') + // .geoDistance(new GeoPoint(39.6985, -105.024)) + // .lessThan(10_000 /* meters */), + // ), + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'solTacos'); + // }); - // TODO(search) enable with backend support - // it('topicality score and snippet', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: field('menu').matches('waffles'), - // addFields: [ - // score().as('searchScore'), - // field('menu').snippet('waffles').as('snippet'), - // ], - // queryEnhancement: 'disabled', - // }) - // .select('name', 'searchScore', 'snippet'); - // - // const snapshot = await ppl.execute(); - // expect(snapshot.results.length).to.equal(1); - // expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); - // expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); - // expect(snapshot.results[0].get('snippet')?.length).to.be.greaterThan( - // 0, - // ); - // }); + it('negate match', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: documentMatches('coffee -waffles'), + // queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'sunnySideUp'); }); - describe('select', () => { - // TODO(search) enable with backend support - // it('topicality score and snippet', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: field('menu').matches('waffles'), - // select: [ - // field('name'), - // 'location', - // score().as('searchScore'), - // field('menu').snippet('waffles').as('snippet'), - // ], - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expect(snapshot.results.length).to.equal(1); - // expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); - // expect(snapshot.results[0].get('location')).to.equal( - // new GeoPoint(39.7183, -104.9621), - // ); - // expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); - // expect(snapshot.results[0].get('snippet')?.length).to.be.greaterThan( - // 0, - // ); - // expect(Object.keys(snapshot.results[0].data()).sort()).to.deep.equal([ - // 'location', - // 'name', - // 'searchScore', - // 'snippet', - // ]); - // }); + // TODO(search) this level of rquery is not yet supported + it.skip('rquery search the document with conjunction and disjunction', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: documentMatches('(waffles OR pancakes) AND coffee'), + // queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); }); - describe('sort', () => { - it('by score', async () => { - const ppl = firestore - .pipeline() - .collection(COLLECTION_NAME) - .search({ - query: documentMatches('tacos'), - sort: score().descending(), - // queryEnhancement: 'disabled', - }); - - const snapshot = await ppl.execute(); - expectResults(snapshot, 'eastsideTacos', 'solTacos'); + it('rquery as query param', async () => { + const ppl = firestore.pipeline().collection(COLLECTION_NAME).search({ + query: 'chicken wings', + // queryEnhancement: 'disabled', }); - // TODO(search) enable with backend support - // it('by distance', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: field('menu').matches('tacos'), - // sort: field('location') - // .geoDistance(new GeoPoint(39.6985, -105.024)) - // .ascending(), - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults(snapshot, 'solTacos', 'eastsideTacos'); - // }); - - // TODO(search) enable with backend support - // it('by multiple orderings', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: field('menu').matches('tacos OR chicken'), - // sort: [ - // field('location') - // .geoDistance(new GeoPoint(39.6985, -105.024)) - // .ascending(), - // score().descending(), - // ], - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults( - // snapshot, - // 'solTacos', - // 'eastsideTacos', - // 'eastsideChicken', - // ); - // }); + const snapshot = await ppl.execute(); + expectResults(snapshot, 'eastsideChicken'); }); - describe('limit', () => { - // it('limits the number of documents returned', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: constant(true), - // sort: field('location') - // .geoDistance(new GeoPoint(39.6985, -105.024)) - // .ascending(), - // limit: 5, - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults( - // snapshot, - // 'solTacos', - // 'lotusBlossomThai', - // 'goldenWaffle', - // ); - // }); - // it('limits the number of documents scored', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: field('menu').matches( - // 'chicken OR tacos OR fish OR waffles', - // ), - // retrievalDepth: 6, - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults( - // snapshot, - // 'eastsideChicken', - // 'eastsideTacos', - // 'solTacos', - // 'mileHighCatch', - // ); - // }); + // TODO(search) enable with advanced rquery support + // it('rquery supports field paths', async () => { + // const ppl = firestore.pipeline().collection(COLLECTION_NAME).search({ + // query: + // 'menu:(waffles OR pancakes) AND description:"breakfast all day"', + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'sunnySideUp'); + // }); + + // TODO(search) enable with support of other expressions in the search stage + // it('conjunction of rquery and expression', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: and( + // documentMatches('tacos'), + // field('average_price_per_person').between(8, 15), + // ), + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'solTacos'); + // }); + }); + + describe('addFields', () => { + it('score', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: documentMatches('waffles'), + addFields: [score().as('searchScore')], + // queryEnhancement: 'disabled', + }) + .select('name', 'searchScore', 'snippet'); + + const snapshot = await ppl.execute(); + expect(snapshot.results.length).to.equal(1); + expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); + expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); }); - describe('offset', () => { - // it('skips N documents', async () => { - // const ppl = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: constant(true), - // limit: 2, - // offset: 2, - // queryEnhancement: 'disabled', - // }); - // - // const snapshot = await ppl.execute(); - // expectResults(snapshot, 'eastsideChicken', 'eastsideTacos'); - // }); + // TODO(search) enable with backend support + // it('topicality score and snippet', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // addFields: [ + // score().as('searchScore'), + // field('menu').snippet('waffles').as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }) + // .select('name', 'searchScore', 'snippet'); + // + // const snapshot = await ppl.execute(); + // expect(snapshot.results.length).to.equal(1); + // expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); + // expect(snapshot.results[0].get('snippet')?.length).to.be.greaterThan( + // 0, + // ); + // }); + }); + + describe('select', () => { + // TODO(search) enable with backend support + // it('topicality score and snippet', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // select: [ + // field('name'), + // 'location', + // score().as('searchScore'), + // field('menu').snippet('waffles').as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expect(snapshot.results.length).to.equal(1); + // expect(snapshot.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot.results[0].get('location')).to.equal( + // new GeoPoint(39.7183, -104.9621), + // ); + // expect(snapshot.results[0].get('searchScore')).to.be.greaterThan(0); + // expect(snapshot.results[0].get('snippet')?.length).to.be.greaterThan( + // 0, + // ); + // expect(Object.keys(snapshot.results[0].data()).sort()).to.deep.equal([ + // 'location', + // 'name', + // 'searchScore', + // 'snippet', + // ]); + // }); + }); + + describe('sort', () => { + it('by score', async () => { + const ppl = firestore + .pipeline() + .collection(COLLECTION_NAME) + .search({ + query: documentMatches('tacos'), + sort: score().descending(), + // queryEnhancement: 'disabled', + }); + + const snapshot = await ppl.execute(); + expectResults(snapshot, 'eastsideTacos', 'solTacos'); }); + + // TODO(search) enable with backend support + // it('by distance', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('tacos'), + // sort: field('location') + // .geoDistance(new GeoPoint(39.6985, -105.024)) + // .ascending(), + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'solTacos', 'eastsideTacos'); + // }); + + // TODO(search) enable with backend support + // it('by multiple orderings', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('tacos OR chicken'), + // sort: [ + // field('location') + // .geoDistance(new GeoPoint(39.6985, -105.024)) + // .ascending(), + // score().descending(), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot = await ppl.execute(); + // expectResults( + // snapshot, + // 'solTacos', + // 'eastsideTacos', + // 'eastsideChicken', + // ); + // }); }); - // TODO(search) add these tests when query enhancement is supported - describe.skip('REQUIRE query expansion', () => { - // it('search full document', async () => { + describe('limit', () => { + // it('limits the number of documents returned', async () => { // const ppl = firestore // .pipeline() // .collection(COLLECTION_NAME) // .search({ - // query: documentMatches('waffles'), - // queryEnhancement: 'required', + // query: constant(true), + // sort: field('location') + // .geoDistance(new GeoPoint(39.6985, -105.024)) + // .ascending(), + // limit: 5, + // queryEnhancement: 'disabled', // }); // // const snapshot = await ppl.execute(); - // expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + // expectResults( + // snapshot, + // 'solTacos', + // 'lotusBlossomThai', + // 'goldenWaffle', + // ); // }); + // it('limits the number of documents scored', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches( + // 'chicken OR tacos OR fish OR waffles', + // ), + // retrievalDepth: 6, + // queryEnhancement: 'disabled', + // }); // - // it('search a specific field', async () => { + // const snapshot = await ppl.execute(); + // expectResults( + // snapshot, + // 'eastsideChicken', + // 'eastsideTacos', + // 'solTacos', + // 'mileHighCatch', + // ); + // }); + }); + + describe('offset', () => { + // it('skips N documents', async () => { // const ppl = firestore // .pipeline() // .collection(COLLECTION_NAME) // .search({ - // query: field('menu').matches('waffles'), - // queryEnhancement: 'required', + // query: constant(true), + // limit: 2, + // offset: 2, + // queryEnhancement: 'disabled', // }); // // const snapshot = await ppl.execute(); - // expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + // expectResults(snapshot, 'eastsideChicken', 'eastsideTacos'); // }); }); }); - // TODO(search) add these tests when snippet expression is supported - describe.skip('snippet', () => { - // it('snippet options', async () => { - // const ppl1 = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: field('menu').matches('waffles'), - // addFields: [ - // field('menu') - // .snippet({ - // rquery: 'waffles', - // maxSnippetWidth: 10, - // }) - // .as('snippet'), - // ], - // queryEnhancement: 'disabled', - // }); - // - // const snapshot1 = await ppl1.execute(); - // expect(snapshot1.results.length).to.equal(1); - // expect(snapshot1.results[0].get('name')).to.equal('The Golden Waffle'); - // expect(snapshot1.results[0].get('snippet')?.length).to.be.greaterThan(0); + // TODO(search) add these tests when query enhancement is supported + describe.skip('REQUIRE query expansion', () => { + // it('search full document', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: documentMatches('waffles'), + // queryEnhancement: 'required', + // }); // - // const ppl2 = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: field('menu').matches('waffles'), - // addFields: [ - // field('menu') - // .snippet({ - // rquery: 'waffles', - // maxSnippetWidth: 1000, - // }) - // .as('snippet'), - // ], - // queryEnhancement: 'disabled', - // }); - // - // const snapshot2 = await ppl2.execute(); - // expect(snapshot2.results.length).to.equal(1); - // expect(snapshot2.results[0].get('name')).to.equal('The Golden Waffle'); - // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan(0); - // - // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan( - // snapshot2.results[0].get('snippet')?.length, - // ); - // }); - // - // it('snippet on multiple fields', async () => { - // // Get snippet from 1 field - // const ppl1 = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: documentMatches('waffle'), - // addFields: [ - // field('menu') - // .snippet({ - // rquery: 'waffles', - // maxSnippetWidth: 2000, - // }) - // .as('snippet'), - // ], - // queryEnhancement: 'disabled', - // }); - // - // const snapshot1 = await ppl1.execute(); - // expect(snapshot1.results.length).to.equal(1); - // expect(snapshot1.results[0].get('name')).to.equal('The Golden Waffle'); - // expect(snapshot1.results[0].get('snippet')?.length).to.be.greaterThan(0); - // - // // Get snippet from 2 fields - // const ppl2 = firestore - // .pipeline() - // .collection(COLLECTION_NAME) - // .search({ - // query: documentMatches('waffle'), - // addFields: [ - // concat(field('menu'), field('description')) - // .snippet({ - // rquery: 'waffles', - // maxSnippetWidth: 2000, - // }) - // .as('snippet'), - // ], - // queryEnhancement: 'disabled', - // }); + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); + // }); // - // const snapshot2 = await ppl2.execute(); - // expect(snapshot2.results.length).to.equal(1); - // expect(snapshot2.results[0].get('name')).to.equal('The Golden Waffle'); - // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan(0); + // it('search a specific field', async () => { + // const ppl = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // queryEnhancement: 'required', + // }); // - // // Expect snippet from 2 fields to be longer than snippet from one field - // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan( - // snapshot2.results[0].get('snippet')?.length, - // ); - // }); + // const snapshot = await ppl.execute(); + // expectResults(snapshot, 'goldenWaffle', 'sunnySideUp'); // }); }); }); + + // TODO(search) add these tests when snippet expression is supported + describe.skip('snippet', () => { + // it('snippet options', async () => { + // const ppl1 = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // addFields: [ + // field('menu') + // .snippet({ + // rquery: 'waffles', + // maxSnippetWidth: 10, + // }) + // .as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot1 = await ppl1.execute(); + // expect(snapshot1.results.length).to.equal(1); + // expect(snapshot1.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot1.results[0].get('snippet')?.length).to.be.greaterThan(0); + // + // const ppl2 = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: field('menu').matches('waffles'), + // addFields: [ + // field('menu') + // .snippet({ + // rquery: 'waffles', + // maxSnippetWidth: 1000, + // }) + // .as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot2 = await ppl2.execute(); + // expect(snapshot2.results.length).to.equal(1); + // expect(snapshot2.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan(0); + // + // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan( + // snapshot2.results[0].get('snippet')?.length, + // ); + // }); + // + // it('snippet on multiple fields', async () => { + // // Get snippet from 1 field + // const ppl1 = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: documentMatches('waffle'), + // addFields: [ + // field('menu') + // .snippet({ + // rquery: 'waffles', + // maxSnippetWidth: 2000, + // }) + // .as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot1 = await ppl1.execute(); + // expect(snapshot1.results.length).to.equal(1); + // expect(snapshot1.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot1.results[0].get('snippet')?.length).to.be.greaterThan(0); + // + // // Get snippet from 2 fields + // const ppl2 = firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search({ + // query: documentMatches('waffle'), + // addFields: [ + // concat(field('menu'), field('description')) + // .snippet({ + // rquery: 'waffles', + // maxSnippetWidth: 2000, + // }) + // .as('snippet'), + // ], + // queryEnhancement: 'disabled', + // }); + // + // const snapshot2 = await ppl2.execute(); + // expect(snapshot2.results.length).to.equal(1); + // expect(snapshot2.results[0].get('name')).to.equal('The Golden Waffle'); + // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan(0); + // + // // Expect snippet from 2 fields to be longer than snippet from one field + // expect(snapshot2.results[0].get('snippet')?.length).to.be.greaterThan( + // snapshot2.results[0].get('snippet')?.length, + // ); + // }); + // }); + }); }); // This is the Query integration tests from the lite API (no cache support)