Skip to content

Commit 4b73b96

Browse files
authored
feat: Firestore managed bulk delete (#8974)
* WIP: Adding firestore:bulk-delete command. * Did clean ups. Manual testing works as expected. * Fixes and add tests. * fix test suite name * Address feedback (1). * address feedback (2). * address feedback (3). * rename bulk-delete to bulkdelete. * Address feedback and add changelog. * Add the requireAuth back for spec tests.
1 parent 821de06 commit 4b73b96

File tree

6 files changed

+233
-0
lines changed

6 files changed

+233
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
- Added `firestore:bulkdelete` which initiates a Firestore managed bulk delete operation (#8974)
2+
- Added `firestore:operations:*` commands to list, describe, and cancel long-running operations (#8982)
13
- `firebase emulator:start` use a default project `demo-no-project` if no project can be found. (#9072)
24
- `firebase init dataconnect` also supports bootstrapping flutter template. (#9084)
35
- Fixed a vulnerability in `unzip` util where files could be written outside of the expected output directory.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import * as sinon from "sinon";
2+
import { expect } from "chai";
3+
import { Command } from "../command";
4+
import { command as firestoreBulkDelete } from "./firestore-bulkdelete";
5+
import * as fsi from "../firestore/api";
6+
import { FirebaseError } from "../error";
7+
import * as requireAuthModule from "../requireAuth";
8+
import { BulkDeleteDocumentsResponse } from "../firestore/api-types";
9+
10+
describe("firestore:bulkdelete", () => {
11+
const PROJECT = "test-project";
12+
const DATABASE = "test-database";
13+
const COLLECTION_IDS = ["collection1", "collection2"];
14+
15+
let command: Command;
16+
let firestoreApiStub: sinon.SinonStubbedInstance<fsi.FirestoreApi>;
17+
let requireAuthStub: sinon.SinonStub;
18+
19+
beforeEach(() => {
20+
command = firestoreBulkDelete;
21+
firestoreApiStub = sinon.createStubInstance(fsi.FirestoreApi);
22+
requireAuthStub = sinon.stub(requireAuthModule, "requireAuth");
23+
sinon.stub(fsi, "FirestoreApi").returns(firestoreApiStub);
24+
requireAuthStub.resolves("a@b.com");
25+
});
26+
27+
afterEach(() => {
28+
sinon.restore();
29+
});
30+
31+
const mockResponse = (name: string): BulkDeleteDocumentsResponse => {
32+
return {
33+
name,
34+
};
35+
};
36+
37+
it("should throw an error if collection-ids is not provided", async () => {
38+
const options = {
39+
project: PROJECT,
40+
};
41+
42+
await expect(command.runner()(options)).to.be.rejectedWith(
43+
FirebaseError,
44+
"Missing required flag --collection-ids=[comma separated list of collection groups]",
45+
);
46+
});
47+
48+
it("should call bulkDeleteDocuments with the correct parameters", async () => {
49+
const options = {
50+
project: PROJECT,
51+
collectionIds: COLLECTION_IDS.join(","),
52+
force: true,
53+
json: true,
54+
};
55+
const expectedResponse = mockResponse("test-operation");
56+
firestoreApiStub.bulkDeleteDocuments.resolves(expectedResponse);
57+
58+
const result = await command.runner()(options);
59+
60+
expect(result).to.deep.equal(expectedResponse);
61+
expect(
62+
firestoreApiStub.bulkDeleteDocuments.calledOnceWith(PROJECT, "(default)", COLLECTION_IDS),
63+
).to.be.true;
64+
});
65+
66+
it("should call bulkDeleteDocuments with the correct database", async () => {
67+
const options = {
68+
project: PROJECT,
69+
database: DATABASE,
70+
collectionIds: COLLECTION_IDS.join(","),
71+
force: true,
72+
json: true,
73+
};
74+
const expectedResponse = mockResponse("test-operation");
75+
firestoreApiStub.bulkDeleteDocuments.resolves(expectedResponse);
76+
77+
const result = await command.runner()(options);
78+
79+
expect(result).to.deep.equal(expectedResponse);
80+
expect(firestoreApiStub.bulkDeleteDocuments.calledOnceWith(PROJECT, DATABASE, COLLECTION_IDS))
81+
.to.be.true;
82+
});
83+
84+
it("should throw an error if the API call fails", async () => {
85+
const options = {
86+
project: PROJECT,
87+
collectionIds: COLLECTION_IDS.join(","),
88+
force: true,
89+
};
90+
const apiError = new Error("API Error");
91+
firestoreApiStub.bulkDeleteDocuments.rejects(apiError);
92+
93+
await expect(command.runner()(options)).to.be.rejectedWith(apiError);
94+
});
95+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Command } from "../command";
2+
import * as fsi from "../firestore/api";
3+
import { logger } from "../logger";
4+
import { requirePermissions } from "../requirePermissions";
5+
import { Emulators } from "../emulator/types";
6+
import { warnEmulatorNotSupported } from "../emulator/commandUtils";
7+
import { FirestoreOptions } from "../firestore/options";
8+
import { confirm } from "../prompt";
9+
import * as utils from "../utils";
10+
import * as clc from "colorette";
11+
import { logBullet, logLabeledError, logSuccess } from "../utils";
12+
import { FirebaseError } from "../error";
13+
14+
function confirmationMessage(
15+
options: FirestoreOptions,
16+
databaseId: string,
17+
collectionIds: string[],
18+
): string {
19+
const root = `projects/${options.project}/databases/${databaseId}/documents`;
20+
return (
21+
"You are about to delete all documents in the following collection groups: " +
22+
clc.cyan(collectionIds.map((item) => `"${item}"`).join(", ")) +
23+
" in " +
24+
clc.cyan(`"${root}"`) +
25+
". Are you sure?"
26+
);
27+
}
28+
29+
export const command = new Command("firestore:bulkdelete")
30+
.description("managed bulk delete service to delete data from one or more collection groups")
31+
.option(
32+
"--database <databaseName>",
33+
'Database ID for database to delete from. "(default)" if none is provided.',
34+
)
35+
.option(
36+
"--collection-ids <collectionIds>",
37+
"A comma-separated list of collection group IDs to delete. Deletes all documents in the specified collection groups.",
38+
)
39+
.before(requirePermissions, ["datastore.databases.bulkDeleteDocuments"])
40+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
41+
.action(async (options: FirestoreOptions) => {
42+
if (!options.collectionIds) {
43+
throw new FirebaseError(
44+
"Missing required flag --collection-ids=[comma separated list of collection groups]",
45+
);
46+
}
47+
let collectionIds: string[] = [];
48+
try {
49+
collectionIds = (options.collectionIds as string)
50+
.split(",")
51+
.filter((id: string) => id.trim() !== "");
52+
} catch (e) {
53+
throw new FirebaseError(
54+
"The value for --collection-ids must a list of comma separated collection group names",
55+
);
56+
}
57+
58+
if (collectionIds.length === 0) {
59+
throw new FirebaseError("Must specify at least one collection ID in --collection-ids.");
60+
}
61+
62+
const databaseId = options.database || "(default)";
63+
64+
const api = new fsi.FirestoreApi();
65+
66+
const confirmed = await confirm({
67+
message: confirmationMessage(options, databaseId, collectionIds),
68+
default: false,
69+
force: options.force,
70+
nonInteractive: options.nonInteractive,
71+
});
72+
if (!confirmed) {
73+
return utils.reject("Command aborted.", { exit: 1 });
74+
}
75+
76+
const op = await api.bulkDeleteDocuments(options.project, databaseId, collectionIds);
77+
78+
if (options.json) {
79+
logger.info(JSON.stringify(op, undefined, 2));
80+
} else {
81+
if (op.name) {
82+
logSuccess(`Successfully started bulk delete operation.`);
83+
logBullet(`Operation name: ` + clc.cyan(op.name));
84+
// TODO: Update this message to 'firebase firestore:operations:describe' command once it's implemented.
85+
logBullet(
86+
"You can monitor the operation's progress using the " +
87+
clc.cyan(`gcloud firestore operations describe`) +
88+
` command.`,
89+
);
90+
} else {
91+
logLabeledError(`Bulk Delete:`, `Failed to start a bulk delete operation.`);
92+
}
93+
}
94+
95+
return op;
96+
});

src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export function load(client: any): any {
101101
client.ext.dev.usage = loadCommand("ext-dev-usage");
102102
client.firestore = {};
103103
client.firestore.delete = loadCommand("firestore-delete");
104+
client.firestore.bulkDelete = loadCommand("firestore-bulkdelete");
104105
client.firestore.indexes = loadCommand("firestore-indexes-list");
105106
client.firestore.locations = loadCommand("firestore-locations");
106107
client.firestore.operations = {};

src/firestore/api-types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,19 @@ export interface DatabaseResp {
183183
databaseEdition?: DatabaseEdition;
184184
}
185185

186+
export interface BulkDeleteDocumentsRequest {
187+
// Database to operate. Should be of the form:
188+
// `projects/{project_id}/databases/{database_id}`.
189+
name: string;
190+
// IDs of the collection groups to delete. Unspecified means *all* collection groups.
191+
// Each collection group in this list must be unique.
192+
collectionIds?: string[];
193+
}
194+
195+
export type BulkDeleteDocumentsResponse = {
196+
name?: string;
197+
};
198+
186199
export interface Operation {
187200
name: string;
188201
done: boolean;

src/firestore/api.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,32 @@ export class FirestoreApi {
835835
return database;
836836
}
837837

838+
/**
839+
* Bulk delete documents from a Firestore database.
840+
* @param project the Firebase project id.
841+
* @param databaseId the id of the Firestore Database.
842+
* @param collectionIds the collection IDs to delete.
843+
*/
844+
async bulkDeleteDocuments(
845+
project: string,
846+
databaseId: string,
847+
collectionIds: string[],
848+
): Promise<types.BulkDeleteDocumentsResponse> {
849+
const name = `/projects/${project}/databases/${databaseId}`;
850+
const url = `${name}:bulkDeleteDocuments`;
851+
const payload: types.BulkDeleteDocumentsRequest = {
852+
name,
853+
collectionIds,
854+
};
855+
const res = await this.apiClient.post<
856+
types.BulkDeleteDocumentsRequest,
857+
types.BulkDeleteDocumentsResponse
858+
>(url, payload);
859+
return {
860+
name: res.body?.name,
861+
};
862+
}
863+
838864
/**
839865
* Restore a Firestore Database from a backup.
840866
* @param project the Firebase project id.

0 commit comments

Comments
 (0)