Skip to content

Commit 0ec7a55

Browse files
authored
feat: add support for vector search and query explain (#125)
This commit introduces two major features: Firestore Vector Search and Query Explain functionality. **Vector Search:** - Adds `FieldValue.vector()` to create `VectorValue` objects for storing vector embeddings in documents. - Introduces `query.findNearest()` to perform vector similarity searches. This returns a `VectorQuery` which can be executed with `.get()`. - Adds `VectorQuerySnapshot` to represent the results of a vector search. - New types `VectorQueryOptions` and `DistanceMeasure` are added to configure vector queries. **Query Explain:** - Adds `query.explain(options)` and `vectorQuery.explain(options)` methods. - These methods return an `ExplainResults` object containing `ExplainMetrics` with plan summaries and optional execution statistics. - New types `ExplainOptions`, `ExplainResults`, `ExplainMetrics`, `PlanSummary`, and `ExecutionStats` are introduced. **Other Changes:** - Adds `size` and `empty` getters to `QuerySnapshot`. - Extends the internal testing helper `firestore.snapshot_()` to support creating snapshots for missing documents.
1 parent fbfd1ff commit 0ec7a55

37 files changed

+2891
-550
lines changed

.github/workflows/build.yml

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,76 @@ jobs:
128128
mkdir -p $HOME/.config/gcloud
129129
echo '${{ secrets.CREDS }}' > $HOME/.config/gcloud/application_default_credentials.json
130130
131-
- name: Run tests with coverage
131+
- name: Run dart_firebase_admin tests with coverage
132132
run: ${{ github.workspace }}/scripts/coverage.sh
133133

134+
- name: Run googleapis_firestore tests with coverage
135+
run: ${{ github.workspace }}/scripts/firestore-coverage.sh
136+
137+
- name: Run googleapis_auth_utils tests with coverage
138+
run: ${{ github.workspace }}/scripts/auth-utils-coverage.sh
139+
140+
- name: Merge coverage reports
141+
run: |
142+
# Save individual package coverage files before merging
143+
cp coverage.lcov coverage_admin.lcov
144+
cp ../googleapis_firestore/coverage.lcov coverage_firestore.lcov
145+
cp ../googleapis_auth_utils/coverage.lcov coverage_auth_utils.lcov
146+
147+
# Merge coverage reports from all packages (relative to packages/dart_firebase_admin)
148+
# Only merge files that exist
149+
COVERAGE_FILES=""
150+
[ -f coverage.lcov ] && COVERAGE_FILES="$COVERAGE_FILES coverage.lcov"
151+
[ -f ../googleapis_firestore/coverage.lcov ] && COVERAGE_FILES="$COVERAGE_FILES ../googleapis_firestore/coverage.lcov"
152+
[ -f ../googleapis_auth_utils/coverage.lcov ] && COVERAGE_FILES="$COVERAGE_FILES ../googleapis_auth_utils/coverage.lcov"
153+
154+
if [ -n "$COVERAGE_FILES" ]; then
155+
cat $COVERAGE_FILES > merged_coverage.lcov
156+
mv merged_coverage.lcov coverage.lcov
157+
else
158+
echo "No coverage files found!"
159+
exit 1
160+
fi
161+
134162
- name: Check coverage threshold and generate report
135163
if: matrix.dart-version == 'stable'
136164
id: coverage
137165
run: |
138-
# coverage.lcov already generated by test_with_coverage in coverage script
139-
140-
# Calculate total coverage
166+
# Calculate coverage for each package
167+
calculate_coverage() {
168+
local file=$1
169+
if [ -f "$file" ]; then
170+
local total=$(grep -E "^LF:" "$file" | awk -F: '{sum+=$2} END {print sum}')
171+
local hit=$(grep -E "^LH:" "$file" | awk -F: '{sum+=$2} END {print sum}')
172+
if [ "$total" -gt 0 ]; then
173+
local pct=$(awk "BEGIN {printf \"%.2f\", ($hit/$total)*100}")
174+
echo "$pct|$hit|$total"
175+
else
176+
echo "0.00|0|0"
177+
fi
178+
else
179+
echo "0.00|0|0"
180+
fi
181+
}
182+
183+
# Get individual package coverage from saved copies
184+
ADMIN_COV=$(calculate_coverage "coverage_admin.lcov")
185+
FIRESTORE_COV=$(calculate_coverage "coverage_firestore.lcov")
186+
AUTH_UTILS_COV=$(calculate_coverage "coverage_auth_utils.lcov")
187+
188+
ADMIN_PCT=$(echo $ADMIN_COV | cut -d'|' -f1)
189+
ADMIN_HIT=$(echo $ADMIN_COV | cut -d'|' -f2)
190+
ADMIN_TOTAL=$(echo $ADMIN_COV | cut -d'|' -f3)
191+
192+
FIRESTORE_PCT=$(echo $FIRESTORE_COV | cut -d'|' -f1)
193+
FIRESTORE_HIT=$(echo $FIRESTORE_COV | cut -d'|' -f2)
194+
FIRESTORE_TOTAL=$(echo $FIRESTORE_COV | cut -d'|' -f3)
195+
196+
AUTH_UTILS_PCT=$(echo $AUTH_UTILS_COV | cut -d'|' -f1)
197+
AUTH_UTILS_HIT=$(echo $AUTH_UTILS_COV | cut -d'|' -f2)
198+
AUTH_UTILS_TOTAL=$(echo $AUTH_UTILS_COV | cut -d'|' -f3)
199+
200+
# Calculate total coverage from merged file
141201
TOTAL_LINES=$(grep -E "^(DA|LF):" coverage.lcov | grep "^LF:" | awk -F: '{sum+=$2} END {print sum}')
142202
HIT_LINES=$(grep -E "^(DA|LH):" coverage.lcov | grep "^LH:" | awk -F: '{sum+=$2} END {print sum}')
143203
@@ -147,11 +207,22 @@ jobs:
147207
COVERAGE_PCT="0.00"
148208
fi
149209
210+
# Output for GitHub Actions
150211
echo "coverage=${COVERAGE_PCT}" >> $GITHUB_OUTPUT
151212
echo "total_lines=${TOTAL_LINES}" >> $GITHUB_OUTPUT
152213
echo "hit_lines=${HIT_LINES}" >> $GITHUB_OUTPUT
153-
154-
echo "Coverage: ${COVERAGE_PCT}% (${HIT_LINES}/${TOTAL_LINES} lines)"
214+
215+
echo "admin_coverage=${ADMIN_PCT}" >> $GITHUB_OUTPUT
216+
echo "firestore_coverage=${FIRESTORE_PCT}" >> $GITHUB_OUTPUT
217+
echo "auth_utils_coverage=${AUTH_UTILS_PCT}" >> $GITHUB_OUTPUT
218+
219+
# Console output
220+
echo "=== Coverage Report ==="
221+
echo "dart_firebase_admin: ${ADMIN_PCT}% (${ADMIN_HIT}/${ADMIN_TOTAL} lines)"
222+
echo "googleapis_firestore: ${FIRESTORE_PCT}% (${FIRESTORE_HIT}/${FIRESTORE_TOTAL} lines)"
223+
echo "googleapis_auth_utils: ${AUTH_UTILS_PCT}% (${AUTH_UTILS_HIT}/${AUTH_UTILS_TOTAL} lines)"
224+
echo "----------------------"
225+
echo "Total: ${COVERAGE_PCT}% (${HIT_LINES}/${TOTAL_LINES} lines)"
155226
156227
# Check threshold
157228
if (( $(echo "$COVERAGE_PCT < 40" | bc -l) )); then
@@ -170,14 +241,24 @@ jobs:
170241
const status = '${{ steps.coverage.outputs.status }}';
171242
const hitLines = '${{ steps.coverage.outputs.hit_lines }}';
172243
const totalLines = '${{ steps.coverage.outputs.total_lines }}';
244+
const adminCov = '${{ steps.coverage.outputs.admin_coverage }}';
245+
const firestoreCov = '${{ steps.coverage.outputs.firestore_coverage }}';
246+
const authUtilsCov = '${{ steps.coverage.outputs.auth_utils_coverage }}';
173247
174248
const body = `## Coverage Report
175249
176250
${status}
177251
178-
**Coverage:** ${coverage}%
252+
**Total Coverage:** ${coverage}%
179253
**Lines Covered:** ${hitLines}/${totalLines}
180254
255+
### Package Breakdown
256+
| Package | Coverage |
257+
|---------|----------|
258+
| dart_firebase_admin | ${adminCov}% |
259+
| googleapis_firestore | ${firestoreCov}% |
260+
| googleapis_auth_utils | ${authUtilsCov}% |
261+
181262
_Minimum threshold: 40%_`;
182263
183264
// Find existing comment

packages/dart_firebase_admin/example/lib/main.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import 'package:dart_firebase_admin/auth.dart';
22
import 'package:dart_firebase_admin/dart_firebase_admin.dart';
33
import 'package:dart_firebase_admin/functions.dart';
44
import 'package:dart_firebase_admin/messaging.dart';
5-
import 'firestore_example.dart';
65

76
Future<void> main() async {
87
final admin = FirebaseApp.initializeApp();
@@ -11,7 +10,7 @@ Future<void> main() async {
1110
// await authExample(admin);
1211

1312
// Uncomment to run firestore example
14-
await firestoreExample(admin);
13+
// await firestoreExample(admin);
1514

1615
// Uncomment to run project config example
1716
// await projectConfigExample(admin);
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
name: dart_firebase_admin_example
22
publish_to: none
3-
resolution: workspace
43

54
environment:
6-
sdk: '>=3.9.0 <4.0.0'
5+
sdk: '^3.9.0'
76

87
dependencies:
9-
dart_firebase_admin: any
10-
googleapis_firestore: any
8+
dart_firebase_admin: ^0.1.0
9+
googleapis_auth_utils: ^0.1.0
10+
googleapis_firestore: ^0.1.0
11+
12+
dependency_overrides:
13+
dart_firebase_admin:
14+
path: ../../dart_firebase_admin
15+
googleapis_auth_utils:
16+
path: ../../googleapis_auth_utils
17+
googleapis_firestore:
18+
path: ../../googleapis_firestore
19+

packages/dart_firebase_admin/test/app/firebase_app_test.dart

Lines changed: 28 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import 'package:googleapis_firestore/googleapis_firestore.dart'
1010
import 'package:mocktail/mocktail.dart';
1111
import 'package:test/test.dart';
1212

13-
import '../helpers.dart';
1413
import '../mock.dart';
1514
import '../mock_service_account.dart';
1615

@@ -235,7 +234,7 @@ void main() {
235234

236235
group('client', () {
237236
test('returns custom client when provided', () async {
238-
final mockClient = MockAuthClient();
237+
final mockClient = ClientMock();
239238
final app = FirebaseApp.initializeApp(
240239
options: AppOptions(projectId: mockProjectId, httpClient: mockClient),
241240
);
@@ -267,38 +266,26 @@ void main() {
267266
// await FirebaseApp.deleteApp(app);
268267
// });
269268

270-
test('reuses same client on subsequent calls', () {
271-
runZoned(() async {
272-
final mockClient = MockAuthClient();
273-
final app = FirebaseApp.initializeApp(
274-
options: AppOptions(
275-
projectId: mockProjectId,
276-
httpClient: mockClient,
277-
),
278-
);
279-
final client1 = await app.client;
280-
final client2 = await app.client;
269+
test('reuses same client on subsequent calls', () async {
270+
final app = FirebaseApp.initializeApp(
271+
options: const AppOptions(projectId: mockProjectId),
272+
);
273+
final client1 = await app.client;
274+
final client2 = await app.client;
281275

282-
expect(identical(client1, client2), isTrue);
276+
expect(identical(client1, client2), isTrue);
283277

284-
await FirebaseApp.deleteApp(app);
285-
}, zoneValues: {envSymbol: <String, String>{}});
278+
await FirebaseApp.deleteApp(app);
286279
});
287280
});
288281

289282
group('service accessors', () {
290283
late FirebaseApp app;
291284

292285
setUp(() {
293-
runZoned(() {
294-
final mockClient = MockAuthClient();
295-
app = FirebaseApp.initializeApp(
296-
options: AppOptions(
297-
projectId: mockProjectId,
298-
httpClient: mockClient,
299-
),
300-
);
301-
}, zoneValues: {});
286+
app = FirebaseApp.initializeApp(
287+
options: const AppOptions(projectId: mockProjectId),
288+
);
302289
});
303290

304291
tearDown(() async {
@@ -334,50 +321,38 @@ void main() {
334321
});
335322

336323
test('firestore returns Firestore instance', () {
337-
final firestore = app.firestore(settings: mockFirestoreSettings);
324+
final firestore = app.firestore();
338325
expect(firestore, isA<googleapis_firestore.Firestore>());
339326
// Verify we can use Firestore methods
340327
expect(firestore.collection('test'), isNotNull);
341328
});
342329

343330
test('firestore returns cached instance', () {
344-
final firestore1 = app.firestore(settings: mockFirestoreSettings);
345-
final firestore2 = app.firestore(settings: mockFirestoreSettings);
331+
final firestore1 = app.firestore();
332+
final firestore2 = app.firestore();
346333
expect(identical(firestore1, firestore2), isTrue);
347334
});
348335

349336
test(
350337
'firestore with different databaseId returns different instances',
351338
() {
352-
final firestore1 = app.firestore(
353-
settings: mockFirestoreSettingsWithDb('db1'),
354-
databaseId: 'db1',
355-
);
356-
final firestore2 = app.firestore(
357-
settings: mockFirestoreSettingsWithDb('db2'),
358-
databaseId: 'db2',
359-
);
339+
final firestore1 = app.firestore(databaseId: 'db1');
340+
final firestore2 = app.firestore(databaseId: 'db2');
360341
expect(identical(firestore1, firestore2), isFalse);
361342
},
362343
);
363344

364345
test('firestore throws when reinitializing with different settings', () {
365346
// Initialize with first settings
366347
app.firestore(
367-
settings: const googleapis_firestore.Settings(
368-
host: 'localhost:8080',
369-
environmentOverride: {'FIRESTORE_EMULATOR_HOST': 'localhost:8080'},
370-
),
348+
settings: const googleapis_firestore.Settings(host: 'localhost:8080'),
371349
);
372350

373351
// Try to initialize again with different settings - should throw
374352
expect(
375353
() => app.firestore(
376354
settings: const googleapis_firestore.Settings(
377355
host: 'different:9090',
378-
environmentOverride: {
379-
'FIRESTORE_EMULATOR_HOST': 'localhost:8080',
380-
},
381356
),
382357
),
383358
throwsA(isA<FirebaseAppException>()),
@@ -423,7 +398,7 @@ void main() {
423398
),
424399
);
425400
expect(
426-
() => app.firestore(settings: mockFirestoreSettings),
401+
() => app.firestore(),
427402
throwsA(
428403
isA<FirebaseAppException>().having(
429404
(e) => e.code,
@@ -469,26 +444,20 @@ void main() {
469444
expect(app.isDeleted, isTrue);
470445
});
471446

472-
test('closes HTTP client when created by SDK', () {
473-
runZoned(() async {
474-
final mockClient = MockAuthClient();
475-
final app = FirebaseApp.initializeApp(
476-
options: AppOptions(
477-
projectId: mockProjectId,
478-
httpClient: mockClient,
479-
),
480-
);
447+
test('closes HTTP client when created by SDK', () async {
448+
final app = FirebaseApp.initializeApp(
449+
options: const AppOptions(projectId: mockProjectId),
450+
);
481451

482-
await app.client;
452+
await app.client;
483453

484-
await app.close();
454+
await app.close();
485455

486-
expect(app.isDeleted, isTrue);
487-
}, zoneValues: {});
456+
expect(app.isDeleted, isTrue);
488457
});
489458

490459
test('does not close custom HTTP client', () async {
491-
final mockClient = MockAuthClient();
460+
final mockClient = ClientMock();
492461
final app = FirebaseApp.initializeApp(
493462
options: AppOptions(projectId: mockProjectId, httpClient: mockClient),
494463
);
@@ -532,7 +501,7 @@ void main() {
532501
await runZoned(zoneValues: {envSymbol: testEnv}, () async {
533502
// Create mocks
534503
final mockHttpClient = AuthHttpClientMock();
535-
final mockClient = MockAuthClient();
504+
final mockClient = ClientMock();
536505

537506
final app = FirebaseApp.initializeApp(
538507
options: const AppOptions(projectId: mockProjectId),

0 commit comments

Comments
 (0)