From 2ab64315b370f3052258c98db09cd7b005cd0fb2 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 21 Oct 2026 11:28:24 +0000 Subject: [PATCH 1/5] fix(firestore,apple): pass findNearest distance field as an expression The parser passed a plain string into an SDK parameter that expects a field expression, so Apple builds warned and setting distance_field crashed. --- .../cloud_firestore/FLTPipelineParser.m | 5 +- .../pipeline/pipeline_find_nearest_e2e.dart | 56 +++++++++++++++++++ .../pipeline_example/pubspec.yaml | 1 + 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m index f416fbc2bd4d..e5ee9faef8de 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m @@ -1603,11 +1603,14 @@ + (__FIRStageBridge *)parseSearchStageWithArgs:(NSDictionary *)args return nil; } FIRVectorValue *queryVector = [[FIRVectorValue alloc] initWithArray:numbers]; + __FIRExprBridge *distanceFieldExpr = + (distanceField.length > 0) ? [[__FIRFieldBridge alloc] initWithName:distanceField] + : nil; stage = [[__FIRFindNearestStageBridge alloc] initWithField:embeddingField vectorValue:queryVector distanceMeasure:distanceMeasure limit:limit - distanceField:distanceField]; + distanceField:distanceFieldExpr]; } else { if (error) *error = parseError([NSString stringWithFormat:@"Unknown pipeline stage: %@", stageName]); diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart index 35d5b6e75062..3338a6902d36 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart @@ -3,7 +3,9 @@ // BSD-style license that can be found in the LICENSE file. import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; void runPipelineFindNearestTests() { @@ -41,5 +43,59 @@ void runPipelineFindNearestTests() { } }, ); + + test( + 'findNearest with distance_field returns that field on Apple and web', + () async { + final prepared = firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('find-nearest')) + .findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.cosine, + limit: 5, + ); + final stages = [ + for (final stage in prepared.stages) + if (stage['stage'] == 'find_nearest') + { + 'stage': stage['stage'], + 'args': { + ...Map.from(stage['args'] as Map), + 'distance_field': 'distance', + }, + } + else + stage, + ]; + final platform = FirebaseFirestorePlatform.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + final snapshot = await platform.pipeline(stages).execute(); + expect(snapshot.results, isA>()); + if (!_distanceFieldIsReturned || snapshot.results.isEmpty) { + return; + } + for (final result in snapshot.results) { + final data = result.data; + expect(data, isNotNull); + expect(data!.containsKey('distance'), isTrue); + expect(data['distance'], isA()); + } + }, + ); }); } + +bool get _distanceFieldIsReturned { + if (kIsWeb) { + return true; + } + return switch (defaultTargetPlatform) { + TargetPlatform.iOS || TargetPlatform.macOS => true, + _ => false, + }; +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml b/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml index b5ad6c908b2e..61efb90f7012 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: cupertino_icons: ^1.0.8 dev_dependencies: + cloud_firestore_platform_interface: ^8.0.8 flutter_test: sdk: flutter integration_test: From 361c712ab4a2fe6b45fb409e90fd364af4b14c22 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 21 Oct 2026 11:30:38 +0000 Subject: [PATCH 2/5] test(firestore): require findNearest distance_field rows on Apple and web An empty result skipped the distance assertion, so the regression could pass without checking the field the parser now sends. --- .../integration_test/pipeline/pipeline_find_nearest_e2e.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart index 3338a6902d36..218c551ac068 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart @@ -76,9 +76,10 @@ void runPipelineFindNearestTests() { ); final snapshot = await platform.pipeline(stages).execute(); expect(snapshot.results, isA>()); - if (!_distanceFieldIsReturned || snapshot.results.isEmpty) { + if (!_distanceFieldIsReturned) { return; } + expect(snapshot.results, isNotEmpty); for (final result in snapshot.results) { final data = result.data; expect(data, isNotNull); From a7f4a54586bf4b635926e3253c8e1cfb89aff2e7 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 21 Oct 2026 12:02:27 +0000 Subject: [PATCH 3/5] feat(firestore): let findNearest name the distance result field Callers can set distanceField so each result includes the calculated distance. Android now forwards that name, matching web and Apple. --- .../utils/PipelineStageHandlers.java | 19 +++-- .../cloud_firestore/lib/src/pipeline.dart | 7 ++ .../lib/src/pipeline_stage.dart | 6 ++ .../pipeline/pipeline_find_nearest_e2e.dart | 77 +++++-------------- .../pipeline_example/pubspec.yaml | 1 - .../test/pipeline_stage_test.dart | 21 +++++ 6 files changed, 69 insertions(+), 62 deletions(-) diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java index 78222ecef7e9..2f93a3dd6c4b 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java @@ -311,6 +311,10 @@ private Pipeline handleFindNearest( List vectorValue = (List) args.get("vector_value"); String distanceMeasureStr = (String) args.get("distance_measure"); Number limitObj = (Number) args.get("limit"); + String distanceField = + args.get("distance_field") instanceof String + ? (String) args.get("distance_field") + : null; if (distanceMeasureStr == null) { throw new IllegalArgumentException("'find_nearest' requires a 'distance_measure' argument"); @@ -327,13 +331,18 @@ private Pipeline handleFindNearest( } Field fieldExpr = Expression.field(vectorField); + boolean hasDistanceField = distanceField != null && !distanceField.isEmpty(); - if (limitObj != null) { + if (limitObj != null || hasDistanceField) { + FindNearestOptions options = new FindNearestOptions(); + if (limitObj != null) { + options = options.withLimit(limitObj.intValue()); + } + if (distanceField != null && !distanceField.isEmpty()) { + options = options.withDistanceField(distanceField); + } return pipeline.findNearest( - vectorField, - Expression.vector(vectorArray), - distanceMeasure, - new FindNearestOptions().withLimit(limitObj.intValue())); + vectorField, Expression.vector(vectorArray), distanceMeasure, options); } else { return pipeline.findNearest(fieldExpr, vectorArray, distanceMeasure); } diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart index 55ca21d79f47..0f656a7f9058 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart @@ -406,6 +406,10 @@ class Pipeline { /// compare vectors (e.g. [DistanceMeasure.cosine]). Use [limit] to return /// only the first N documents. /// + /// [distanceField] is the name of a field added to each result. That field + /// holds the calculated distance from [vectorValue]. Omit it to return the + /// nearest documents without the numeric distance. + /// /// Example: /// ```dart /// firestore.pipeline().collection('books').findNearest( @@ -413,6 +417,7 @@ class Pipeline { /// [0.1, 0.2, 0.3], /// DistanceMeasure.cosine, /// limit: 10, + /// distanceField: 'distance', /// ); /// ``` Pipeline findNearest( @@ -420,12 +425,14 @@ class Pipeline { List vectorValue, DistanceMeasure distanceMeasure, { int? limit, + String? distanceField, }) { final stage = _FindNearestStage( vectorField, vectorValue, distanceMeasure, limit: limit, + distanceField: distanceField, ); return Pipeline._( _firestore, diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart index e6eaf8df20f5..01385ecfd2fb 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart @@ -179,12 +179,14 @@ final class _FindNearestStage extends PipelineStage { final List vectorValue; final DistanceMeasure distanceMeasure; final int? limit; + final String? distanceField; _FindNearestStage( this.vectorField, this.vectorValue, this.distanceMeasure, { this.limit, + this.distanceField, }); @override @@ -203,6 +205,10 @@ final class _FindNearestStage extends PipelineStage { if (limit != null) { map['args']['limit'] = limit; } + final outputField = distanceField; + if (outputField != null && outputField.isNotEmpty) { + map['args']['distance_field'] = outputField; + } return map; } } diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart index 218c551ac068..4425b63e5cef 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart @@ -3,9 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; void runPipelineFindNearestTests() { @@ -44,59 +42,26 @@ void runPipelineFindNearestTests() { }, ); - test( - 'findNearest with distance_field returns that field on Apple and web', - () async { - final prepared = firestore - .pipeline() - .collection('pipeline-e2e') - .where(Expression.field('test').equalValue('find-nearest')) - .findNearest( - Field('embedding'), - [0.1, 0.2, 0.3], - DistanceMeasure.cosine, - limit: 5, - ); - final stages = [ - for (final stage in prepared.stages) - if (stage['stage'] == 'find_nearest') - { - 'stage': stage['stage'], - 'args': { - ...Map.from(stage['args'] as Map), - 'distance_field': 'distance', - }, - } - else - stage, - ]; - final platform = FirebaseFirestorePlatform.instanceFor( - app: Firebase.app(), - databaseId: 'firestore-pipeline-test', - ); - final snapshot = await platform.pipeline(stages).execute(); - expect(snapshot.results, isA>()); - if (!_distanceFieldIsReturned) { - return; - } - expect(snapshot.results, isNotEmpty); - for (final result in snapshot.results) { - final data = result.data; - expect(data, isNotNull); - expect(data!.containsKey('distance'), isTrue); - expect(data['distance'], isA()); - } - }, - ); + test('findNearest distanceField is present on each result', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('find-nearest')) + .findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.cosine, + limit: 5, + distanceField: 'distance', + ) + .execute(); + expect(snapshot.result, isNotEmpty); + for (final result in snapshot.result) { + final data = result.data(); + expect(data, isNotNull); + expect(data!.containsKey('distance'), isTrue); + expect(data['distance'], isA()); + } + }); }); } - -bool get _distanceFieldIsReturned { - if (kIsWeb) { - return true; - } - return switch (defaultTargetPlatform) { - TargetPlatform.iOS || TargetPlatform.macOS => true, - _ => false, - }; -} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml b/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml index 61efb90f7012..b5ad6c908b2e 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml @@ -41,7 +41,6 @@ dependencies: cupertino_icons: ^1.0.8 dev_dependencies: - cloud_firestore_platform_interface: ^8.0.8 flutter_test: sdk: flutter integration_test: diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart index 41a6fba094db..09197e836f04 100644 --- a/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart @@ -396,6 +396,27 @@ void main() { final stage = pipeline.stages.last; expect(stage['args']['distance_measure'], 'dotProduct'); }); + + test('serializes findNearest with distanceField', () { + final pipeline = firestore.pipeline().collection('items').findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.cosine, + distanceField: 'distance', + ); + final stage = pipeline.stages.last; + expect(stage['args']['distance_field'], 'distance'); + }); + + test('omits distance_field when distanceField is absent', () { + final pipeline = firestore.pipeline().collection('items').findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.cosine, + ); + final stage = pipeline.stages.last; + expect(stage['args'].containsKey('distance_field'), isFalse); + }); }); group('_SearchStage', () { From cfbe5234d8bf32f973fda60ef073136607fbe191 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 21 Oct 2026 12:07:55 +0000 Subject: [PATCH 4/5] docs(firestore): describe findNearest distanceField as a field name A dot in the name separates path segments, matching the native SDKs. An empty name is omitted from the stage map. --- .../cloud_firestore/lib/src/pipeline.dart | 8 +++++--- .../cloud_firestore/test/pipeline_stage_test.dart | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart index 0f656a7f9058..083689c0143c 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart @@ -406,9 +406,11 @@ class Pipeline { /// compare vectors (e.g. [DistanceMeasure.cosine]). Use [limit] to return /// only the first N documents. /// - /// [distanceField] is the name of a field added to each result. That field - /// holds the calculated distance from [vectorValue]. Omit it to return the - /// nearest documents without the numeric distance. + /// [distanceField] is the Firestore field name added to each result. That + /// field holds the calculated distance from [vectorValue]. A dot in the + /// name separates path segments, as it does for any other Firestore field + /// name. Omit it to return the nearest documents without the numeric + /// distance. /// /// Example: /// ```dart diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart index 09197e836f04..14485f6e6097 100644 --- a/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart @@ -417,6 +417,17 @@ void main() { final stage = pipeline.stages.last; expect(stage['args'].containsKey('distance_field'), isFalse); }); + + test('omits distance_field when distanceField is empty', () { + final pipeline = firestore.pipeline().collection('items').findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.cosine, + distanceField: '', + ); + final stage = pipeline.stages.last; + expect(stage['args'].containsKey('distance_field'), isFalse); + }); }); group('_SearchStage', () { From f7ef60fc1eb33c37b3868009443b595e06b69448 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 21 Oct 2026 12:33:29 +0000 Subject: [PATCH 5/5] style(firestore): format the Android distance field read The format check wants the ternary on one line. --- .../firebase/firestore/utils/PipelineStageHandlers.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java index 2f93a3dd6c4b..3f8072cb19af 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java @@ -312,9 +312,7 @@ private Pipeline handleFindNearest( String distanceMeasureStr = (String) args.get("distance_measure"); Number limitObj = (Number) args.get("limit"); String distanceField = - args.get("distance_field") instanceof String - ? (String) args.get("distance_field") - : null; + args.get("distance_field") instanceof String ? (String) args.get("distance_field") : null; if (distanceMeasureStr == null) { throw new IllegalArgumentException("'find_nearest' requires a 'distance_measure' argument");