Skip to content

Testing composite index queries against production #5352

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Oct 5, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ jobs:
with:
credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v0
# create composite indexes with Terraform
- name: Setup Terraform
if: contains(matrix.module, ':firebase-firestore')
uses: hashicorp/setup-terraform@v2
- name: Terraform Init
if: contains(matrix.module, ':firebase-firestore')
run: terraform init
continue-on-error: true
- name: Terraform Apply
if: github.event_name == 'pull_request' && contains(matrix.module, ':firebase-firestore')
run: terraform apply -var-file=google-services.json -auto-approve
continue-on-error: true
- name: ${{ matrix.module }} Integ Tests
env:
FIREBASE_CI: 1
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ smoke-test-logs/
smoke-tests/build-debug-headGit-smoke-test
smoke-tests/firehorn.log
macrobenchmark-output.json

# generated Terraform docs
.terraform/*
.terraform.lock.hcl
*.tfstate
*.tfstate.*
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.firebase.firestore;

import static com.google.firebase.firestore.Filter.equalTo;
import static com.google.firebase.firestore.Filter.greaterThan;
import static com.google.firebase.firestore.Filter.or;
import static com.google.firebase.firestore.testutil.TestUtil.map;

import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.firebase.firestore.testutil.CompositeIndexTestHelper;
import com.google.firebase.firestore.testutil.IntegrationTestUtil;
import java.util.Map;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class CompositeIndexQueryTest {

@After
public void tearDown() {
IntegrationTestUtil.tearDown();
}

@Test
public void testOrQueriesWithCompositeIndexes() {
CompositeIndexTestHelper testHelper = new CompositeIndexTestHelper();
Map<String, Map<String, Object>> testDocs =
map(
"doc1", map("a", 1, "b", 0),
"doc2", map("a", 2, "b", 1),
"doc3", map("a", 3, "b", 2),
"doc4", map("a", 1, "b", 3),
"doc5", map("a", 1, "b", 1));
CollectionReference collection = testHelper.withTestDocs(testDocs);

// with one inequality: a>2 || b==1.
testHelper.assertOnlineAndOfflineResultsMatch(
testHelper.query(collection.where(or(greaterThan("a", 2), equalTo("b", 1)))),
"doc5",
"doc2",
"doc3");

// Test with limits (implicit order by ASC): (a==1) || (b > 0) LIMIT 2
testHelper.assertOnlineAndOfflineResultsMatch(
testHelper.query(collection.where(or(equalTo("a", 1), greaterThan("b", 0))).limit(2)),
"doc1",
"doc2");

// Test with limits (explicit order by): (a==1) || (b > 0) LIMIT_TO_LAST 2
// Note: The public query API does not allow implicit ordering when limitToLast is used.
testHelper.assertOnlineAndOfflineResultsMatch(
testHelper.query(
collection.where(or(equalTo("a", 1), greaterThan("b", 0))).limitToLast(2).orderBy("b")),
"doc3",
"doc4");

// Test with limits (explicit order by ASC): (a==2) || (b == 1) ORDER BY a LIMIT 1
testHelper.assertOnlineAndOfflineResultsMatch(
testHelper.query(
collection.where(or(equalTo("a", 2), equalTo("b", 1))).limit(1).orderBy("a")),
"doc5");

// Test with limits (explicit order by DESC): (a==2) || (b == 1) ORDER BY a LIMIT_TO_LAST 1
testHelper.assertOnlineAndOfflineResultsMatch(
testHelper.query(
collection.where(or(equalTo("a", 2), equalTo("b", 1))).limitToLast(1).orderBy("a")),
"doc2");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import static com.google.firebase.firestore.Filter.notInArray;
import static com.google.firebase.firestore.Filter.or;
import static com.google.firebase.firestore.remote.TestingHooksUtil.captureExistenceFilterMismatches;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.checkOnlineAndOfflineResultsMatch;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.isRunningAgainstEmulator;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.nullList;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.querySnapshotToIds;
Expand Down Expand Up @@ -78,25 +79,6 @@ public void tearDown() {
IntegrationTestUtil.tearDown();
}

/**
* Checks that running the query while online (against the backend/emulator) results in the same
* documents as running the query while offline. If `expectedDocs` is provided, it also checks
* that both online and offline query result is equal to the expected documents.
*
* @param query The query to check
* @param expectedDocs Ordered list of document keys that are expected to match the query
*/
public void checkOnlineAndOfflineResultsMatch(Query query, String... expectedDocs) {
QuerySnapshot docsFromServer = waitFor(query.get(Source.SERVER));
QuerySnapshot docsFromCache = waitFor(query.get(Source.CACHE));

assertEquals(querySnapshotToIds(docsFromServer), querySnapshotToIds(docsFromCache));
List<String> expected = asList(expectedDocs);
if (!expected.isEmpty()) {
assertEquals(expected, querySnapshotToIds(docsFromCache));
}
}

@Test
public void testLimitQueries() {
CollectionReference collection =
Expand Down Expand Up @@ -1522,46 +1504,6 @@ public void testOrQueries() {
collection.where(or(equalTo("a", 2), equalTo("b", 1))).limit(1), "doc2");
}

@Test
public void testOrQueriesWithCompositeIndexes() {
assumeTrue(
"Skip this test if running against production because it results in a "
+ "'missing index' error. The Firestore Emulator, however, does serve these "
+ " queries.",
isRunningAgainstEmulator());
Map<String, Map<String, Object>> testDocs =
map(
"doc1", map("a", 1, "b", 0),
"doc2", map("a", 2, "b", 1),
"doc3", map("a", 3, "b", 2),
"doc4", map("a", 1, "b", 3),
"doc5", map("a", 1, "b", 1));
CollectionReference collection = testCollectionWithDocs(testDocs);

// with one inequality: a>2 || b==1.
checkOnlineAndOfflineResultsMatch(
collection.where(or(greaterThan("a", 2), equalTo("b", 1))), "doc5", "doc2", "doc3");

// Test with limits (implicit order by ASC): (a==1) || (b > 0) LIMIT 2
checkOnlineAndOfflineResultsMatch(
collection.where(or(equalTo("a", 1), greaterThan("b", 0))).limit(2), "doc1", "doc2");

// Test with limits (explicit order by): (a==1) || (b > 0) LIMIT_TO_LAST 2
// Note: The public query API does not allow implicit ordering when limitToLast is used.
checkOnlineAndOfflineResultsMatch(
collection.where(or(equalTo("a", 1), greaterThan("b", 0))).limitToLast(2).orderBy("b"),
"doc3",
"doc4");

// Test with limits (explicit order by ASC): (a==2) || (b == 1) ORDER BY a LIMIT 1
checkOnlineAndOfflineResultsMatch(
collection.where(or(equalTo("a", 2), equalTo("b", 1))).limit(1).orderBy("a"), "doc5");

// Test with limits (explicit order by DESC): (a==2) || (b == 1) ORDER BY a LIMIT_TO_LAST 1
checkOnlineAndOfflineResultsMatch(
collection.where(or(equalTo("a", 2), equalTo("b", 1))).limitToLast(1).orderBy("a"), "doc2");
}

@Test
public void testOrQueriesWithIn() {
Map<String, Map<String, Object>> testDocs =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.firebase.firestore.testutil;

import static com.google.firebase.firestore.testutil.IntegrationTestUtil.checkOnlineAndOfflineResultsMatch;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testFirestore;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.writeAllDocs;
import static com.google.firebase.firestore.util.Util.autoId;

import androidx.annotation.NonNull;
import com.google.android.gms.tasks.Task;
import com.google.firebase.Timestamp;
import com.google.firebase.firestore.CollectionReference;
import com.google.firebase.firestore.DocumentReference;
import com.google.firebase.firestore.Query;
import java.util.HashMap;
import java.util.Map;

/**
* This helper class is designed to facilitate integration testing of Firestore queries that require
* composite indexes within a controlled testing environment.
*
* <p>Key Features: - Runs tests against the dedicated test collection with predefined composite
* indexes. - Automatically associates a test ID with documents for data isolation. - Utilizes TTL
* policy for automatic test data cleanup. - Constructs Firestore queries with test ID filters.
*/
public class CompositeIndexTestHelper {
private final String testId;
private static final String TEST_ID_FIELD = "testId";
private static final String COMPOSITE_INDEX_TEST_COLLECTION = "composite-index-test-collection";

// Creates a new instance of the CompositeIndexTestHelper class, with a unique test
// identifier for data isolation.
public CompositeIndexTestHelper() {
this.testId = "test-id-" + autoId();
}

// Runs a test with specified documents in the COMPOSITE_INDEX_TEST_COLLECTION.
@NonNull
public CollectionReference withTestDocs(@NonNull Map<String, Map<String, Object>> docs) {
CollectionReference writer = testFirestore().collection(COMPOSITE_INDEX_TEST_COLLECTION);
writeAllDocs(writer, prepareTestDocuments(docs));
CollectionReference reader = testFirestore().collection(writer.getPath());
return reader;
}

// Adds a filter on test id for a query.
@NonNull
public Query query(@NonNull Query query_) {
return query_.whereEqualTo(TEST_ID_FIELD, testId);
}

// Hash the document key with testId.
private String toHashedId(String docId) {
return docId + '-' + testId;
}

private String[] toHashedIds(String[] docs) {
String[] hashedIds = new String[docs.length];
for (int i = 0; i < docs.length; i++) {
hashedIds[i] = toHashedId(docs[i]);
}
return hashedIds;
}

// Adds test-specific fields to a document, including the testId and expiration date.
private Map<String, Object> addTestSpecificFieldsToDoc(Map<String, Object> doc) {
Map<String, Object> updatedDoc = new HashMap<>(doc);
updatedDoc.put(TEST_ID_FIELD, testId);
updatedDoc.put(
"expireAt",
new Timestamp( // Expire test data after 24 hours
Timestamp.now().getSeconds() + 24 * 60 * 60, Timestamp.now().getNanoseconds()));
return updatedDoc;
}

// Helper method to hash document keys and add test-specific fields for the provided documents.
private Map<String, Map<String, Object>> prepareTestDocuments(
Map<String, Map<String, Object>> docs) {
Map<String, Map<String, Object>> result = new HashMap<>();
for (String key : docs.keySet()) {
Map<String, Object> doc = addTestSpecificFieldsToDoc(docs.get(key));
result.put(toHashedId(key), doc);
}
return result;
}

// Asserts that the result of running the query while online (against the backend/emulator) is
// the same as running it while offline. The expected document Ids are hashed to match the
// actual document IDs created by the test helper.
@NonNull
public void assertOnlineAndOfflineResultsMatch(
@NonNull Query query, @NonNull String... expectedDocs) {
checkOnlineAndOfflineResultsMatch(query, toHashedIds(expectedDocs));
}

// Adds a document to a Firestore collection with test-specific fields.
@NonNull
public Task<DocumentReference> addDoc(
@NonNull CollectionReference collection, @NonNull Map<String, Object> data) {
return collection.add(addTestSpecificFieldsToDoc(data));
}

// Sets a document in Firestore with test-specific fields.
@NonNull
public Task<Void> setDoc(@NonNull DocumentReference document, @NonNull Map<String, Object> data) {
return document.set(addTestSpecificFieldsToDoc(data));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import static com.google.firebase.firestore.testutil.TestUtil.map;
import static com.google.firebase.firestore.util.Util.autoId;
import static java.util.Arrays.asList;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;

import android.content.Context;
Expand All @@ -35,7 +37,9 @@
import com.google.firebase.firestore.FirebaseFirestoreSettings;
import com.google.firebase.firestore.ListenerRegistration;
import com.google.firebase.firestore.MetadataChanges;
import com.google.firebase.firestore.Query;
import com.google.firebase.firestore.QuerySnapshot;
import com.google.firebase.firestore.Source;
import com.google.firebase.firestore.WriteBatch;
import com.google.firebase.firestore.auth.User;
import com.google.firebase.firestore.core.DatabaseInfo;
Expand Down Expand Up @@ -508,4 +512,23 @@ public static List<Object> nullList() {
nullArray.add(null);
return nullArray;
}

/**
* Checks that running the query while online (against the backend/emulator) results in the same
* documents as running the query while offline. If `expectedDocs` is provided, it also checks
* that both online and offline query result is equal to the expected documents.
*
* @param query The query to check
* @param expectedDocs Ordered list of document keys that are expected to match the query
*/
public static void checkOnlineAndOfflineResultsMatch(Query query, String... expectedDocs) {
QuerySnapshot docsFromServer = waitFor(query.get(Source.SERVER));
QuerySnapshot docsFromCache = waitFor(query.get(Source.CACHE));

assertEquals(querySnapshotToIds(docsFromServer), querySnapshotToIds(docsFromCache));
List<String> expected = asList(expectedDocs);
if (!expected.isEmpty()) {
assertEquals(expected, querySnapshotToIds(docsFromCache));
}
}
}
Loading