Skip to content

Commit 8e3290e

Browse files
feat(fdc): Data Connect Bulk Import (#2905)
Add new API interfaces for insert, insertMany, upsert, upsertMany operations.
1 parent c309057 commit 8e3290e

6 files changed

+737
-19
lines changed

etc/firebase-admin.data-connect.api.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ export class DataConnect {
2929
executeGraphql<GraphqlResponse, Variables>(query: string, options?: GraphqlOptions<Variables>): Promise<ExecuteGraphqlResponse<GraphqlResponse>>;
3030
// @beta
3131
executeGraphqlRead<GraphqlResponse, Variables>(query: string, options?: GraphqlOptions<Variables>): Promise<ExecuteGraphqlResponse<GraphqlResponse>>;
32+
// @beta
33+
insert<GraphQlResponse, Variables extends object>(tableName: string, variables: Variables): Promise<ExecuteGraphqlResponse<GraphQlResponse>>;
34+
// @beta
35+
insertMany<GraphQlResponse, Variables extends Array<unknown>>(tableName: string, variables: Variables): Promise<ExecuteGraphqlResponse<GraphQlResponse>>;
36+
// @beta
37+
upsert<GraphQlResponse, Variables extends object>(tableName: string, variables: Variables): Promise<ExecuteGraphqlResponse<GraphQlResponse>>;
38+
// @beta
39+
upsertMany<GraphQlResponse, Variables extends Array<unknown>>(tableName: string, variables: Variables): Promise<ExecuteGraphqlResponse<GraphQlResponse>>;
3240
}
3341

3442
// @public

src/data-connect/data-connect-api-client-internal.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,203 @@ export class DataConnectApiClient {
198198
const message = error.message || `Unknown server error: ${response.text}`;
199199
return new FirebaseDataConnectError(code, message);
200200
}
201+
202+
/**
203+
* Converts JSON data into a GraphQL literal string.
204+
* Handles nested objects, arrays, strings, numbers, and booleans.
205+
* Ensures strings are properly escaped.
206+
*/
207+
private objectToString(data: unknown): string {
208+
if (typeof data === 'string') {
209+
const escapedString = data
210+
.replace(/\\/g, '\\\\') // Replace \ with \\
211+
.replace(/"/g, '\\"'); // Replace " with \"
212+
return `"${escapedString}"`;
213+
}
214+
if (typeof data === 'number' || typeof data === 'boolean' || data === null) {
215+
return String(data);
216+
}
217+
if (validator.isArray(data)) {
218+
const elements = data.map(item => this.objectToString(item)).join(', ');
219+
return `[${elements}]`;
220+
}
221+
if (typeof data === 'object' && data !== null) {
222+
// Filter out properties where the value is undefined BEFORE mapping
223+
const kvPairs = Object.entries(data)
224+
.filter(([, val]) => val !== undefined)
225+
.map(([key, val]) => {
226+
// GraphQL object keys are typically unquoted.
227+
return `${key}: ${this.objectToString(val)}`;
228+
});
229+
230+
if (kvPairs.length === 0) {
231+
return '{}'; // Represent an object with no defined properties as {}
232+
}
233+
return `{ ${kvPairs.join(', ')} }`;
234+
}
235+
236+
// If value is undefined (and not an object property, which is handled above,
237+
// e.g., if objectToString(undefined) is called directly or for an array element)
238+
// it should be represented as 'null'.
239+
if (typeof data === 'undefined') {
240+
return 'null';
241+
}
242+
243+
// Fallback for any other types (e.g., Symbol, BigInt - though less common in GQL contexts)
244+
// Consider how these should be handled or if an error should be thrown.
245+
// For now, simple string conversion.
246+
return String(data);
247+
}
248+
249+
private formatTableName(tableName: string): string {
250+
// Format tableName: first character to lowercase
251+
if (tableName && tableName.length > 0) {
252+
return tableName.charAt(0).toLowerCase() + tableName.slice(1);
253+
}
254+
return tableName;
255+
}
256+
257+
private handleBulkImportErrors(err: FirebaseDataConnectError): never {
258+
if (err.code === `data-connect/${DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR}`){
259+
throw new FirebaseDataConnectError(
260+
DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR,
261+
`${err.message}. Make sure that your table name passed in matches the type name in your GraphQL schema file.`);
262+
}
263+
throw err;
264+
}
265+
266+
/**
267+
* Insert a single row into the specified table.
268+
*/
269+
public async insert<GraphQlResponse, Variables extends object>(
270+
tableName: string,
271+
data: Variables,
272+
): Promise<ExecuteGraphqlResponse<GraphQlResponse>> {
273+
if (!validator.isNonEmptyString(tableName)) {
274+
throw new FirebaseDataConnectError(
275+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
276+
'`tableName` must be a non-empty string.');
277+
}
278+
if (validator.isArray(data)) {
279+
throw new FirebaseDataConnectError(
280+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
281+
'`data` must be an object, not an array, for single insert. For arrays, please use `insertMany` function.');
282+
}
283+
if (!validator.isNonNullObject(data)) {
284+
throw new FirebaseDataConnectError(
285+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
286+
'`data` must be a non-null object.');
287+
}
288+
289+
try {
290+
tableName = this.formatTableName(tableName);
291+
const gqlDataString = this.objectToString(data);
292+
const mutation = `mutation { ${tableName}_insert(data: ${gqlDataString}) }`;
293+
// Use internal executeGraphql
294+
return this.executeGraphql<GraphQlResponse, Variables>(mutation).catch(this.handleBulkImportErrors);
295+
} catch (e: any) {
296+
throw new FirebaseDataConnectError(
297+
DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL,
298+
`Failed to construct insert mutation: ${e.message}`);
299+
}
300+
}
301+
302+
/**
303+
* Insert multiple rows into the specified table.
304+
*/
305+
public async insertMany<GraphQlResponse, Variables extends Array<unknown>>(
306+
tableName: string,
307+
data: Variables,
308+
): Promise<ExecuteGraphqlResponse<GraphQlResponse>> {
309+
if (!validator.isNonEmptyString(tableName)) {
310+
throw new FirebaseDataConnectError(
311+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
312+
'`tableName` must be a non-empty string.');
313+
}
314+
if (!validator.isNonEmptyArray(data)) {
315+
throw new FirebaseDataConnectError(
316+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
317+
'`data` must be a non-empty array for insertMany.');
318+
}
319+
320+
try {
321+
tableName = this.formatTableName(tableName);
322+
const gqlDataString = this.objectToString(data);
323+
const mutation = `mutation { ${tableName}_insertMany(data: ${gqlDataString}) }`;
324+
// Use internal executeGraphql
325+
return this.executeGraphql<GraphQlResponse, Variables>(mutation).catch(this.handleBulkImportErrors);
326+
} catch (e: any) {
327+
throw new FirebaseDataConnectError(DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL,
328+
`Failed to construct insertMany mutation: ${e.message}`);
329+
}
330+
}
331+
332+
/**
333+
* Insert a single row into the specified table, or update it if it already exists.
334+
*/
335+
public async upsert<GraphQlResponse, Variables extends object>(
336+
tableName: string,
337+
data: Variables,
338+
): Promise<ExecuteGraphqlResponse<GraphQlResponse>> {
339+
if (!validator.isNonEmptyString(tableName)) {
340+
throw new FirebaseDataConnectError(
341+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
342+
'`tableName` must be a non-empty string.');
343+
}
344+
if (validator.isArray(data)) {
345+
throw new FirebaseDataConnectError(
346+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
347+
'`data` must be an object, not an array, for single upsert. For arrays, please use `upsertMany` function.');
348+
}
349+
if (!validator.isNonNullObject(data)) {
350+
throw new FirebaseDataConnectError(
351+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
352+
'`data` must be a non-null object.');
353+
}
354+
355+
try {
356+
tableName = this.formatTableName(tableName);
357+
const gqlDataString = this.objectToString(data);
358+
const mutation = `mutation { ${tableName}_upsert(data: ${gqlDataString}) }`;
359+
// Use internal executeGraphql
360+
return this.executeGraphql<GraphQlResponse, Variables>(mutation).catch(this.handleBulkImportErrors);
361+
} catch (e: any) {
362+
throw new FirebaseDataConnectError(
363+
DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL,
364+
`Failed to construct upsert mutation: ${e.message}`);
365+
}
366+
}
367+
368+
/**
369+
* Insert multiple rows into the specified table, or update them if they already exist.
370+
*/
371+
public async upsertMany<GraphQlResponse, Variables extends Array<unknown>>(
372+
tableName: string,
373+
data: Variables,
374+
): Promise<ExecuteGraphqlResponse<GraphQlResponse>> {
375+
if (!validator.isNonEmptyString(tableName)) {
376+
throw new FirebaseDataConnectError(
377+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
378+
'`tableName` must be a non-empty string.');
379+
}
380+
if (!validator.isNonEmptyArray(data)) {
381+
throw new FirebaseDataConnectError(
382+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
383+
'`data` must be a non-empty array for upsertMany.');
384+
}
385+
386+
try {
387+
tableName = this.formatTableName(tableName);
388+
const gqlDataString = this.objectToString(data);
389+
const mutation = `mutation { ${tableName}_upsertMany(data: ${gqlDataString}) }`;
390+
// Use internal executeGraphql
391+
return this.executeGraphql<GraphQlResponse, Variables>(mutation).catch(this.handleBulkImportErrors);
392+
} catch (e: any) {
393+
throw new FirebaseDataConnectError(
394+
DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL,
395+
`Failed to construct upsertMany mutation: ${e.message}`);
396+
}
397+
}
201398
}
202399

203400
/**

src/data-connect/data-connect.ts

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ export class DataConnectService {
4646
}
4747

4848
/**
49-
* Returns the app associated with this `DataConnectService` instance.
50-
*
51-
* @returns The app associated with this `DataConnectService` instance.
52-
*/
49+
* Returns the app associated with this `DataConnectService` instance.
50+
*
51+
* @returns The app associated with this `DataConnectService` instance.
52+
*/
5353
get app(): App {
5454
return this.appInternal;
5555
}
@@ -63,24 +63,24 @@ export class DataConnect {
6363
private readonly client: DataConnectApiClient;
6464

6565
/**
66-
* @param connectorConfig - The connector configuration.
67-
* @param app - The app for this `DataConnect` service.
68-
* @constructor
69-
* @internal
70-
*/
66+
* @param connectorConfig - The connector configuration.
67+
* @param app - The app for this `DataConnect` service.
68+
* @constructor
69+
* @internal
70+
*/
7171
constructor(readonly connectorConfig: ConnectorConfig, readonly app: App) {
7272
this.client = new DataConnectApiClient(connectorConfig, app);
7373
}
7474

7575
/**
76-
* Execute an arbitrary GraphQL query or mutation
77-
*
78-
* @param query - The GraphQL query or mutation.
79-
* @param options - Optional {@link GraphqlOptions} when executing a GraphQL query or mutation.
80-
*
81-
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
82-
* @beta
83-
*/
76+
* Execute an arbitrary GraphQL query or mutation
77+
*
78+
* @param query - The GraphQL query or mutation.
79+
* @param options - Optional {@link GraphqlOptions} when executing a GraphQL query or mutation.
80+
*
81+
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
82+
* @beta
83+
*/
8484
public executeGraphql<GraphqlResponse, Variables>(
8585
query: string,
8686
options?: GraphqlOptions<Variables>,
@@ -103,4 +103,64 @@ export class DataConnect {
103103
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
104104
return this.client.executeGraphqlRead(query, options);
105105
}
106+
107+
/**
108+
* Insert a single row into the specified table.
109+
*
110+
* @param tableName - The name of the table to insert data into.
111+
* @param variables - The data object to insert. The keys should correspond to the column names.
112+
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
113+
* @beta
114+
*/
115+
public insert<GraphQlResponse, Variables extends object>(
116+
tableName: string,
117+
variables: Variables,
118+
): Promise<ExecuteGraphqlResponse<GraphQlResponse>> {
119+
return this.client.insert(tableName, variables);
120+
}
121+
122+
/**
123+
* Insert multiple rows into the specified table.
124+
*
125+
* @param tableName - The name of the table to insert data into.
126+
* @param variables - An array of data objects to insert. Each object's keys should correspond to the column names.
127+
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
128+
* @beta
129+
*/
130+
public insertMany<GraphQlResponse, Variables extends Array<unknown>>(
131+
tableName: string,
132+
variables: Variables,
133+
): Promise<ExecuteGraphqlResponse<GraphQlResponse>> {
134+
return this.client.insertMany(tableName, variables);
135+
}
136+
137+
/**
138+
* Insert a single row into the specified table, or update it if it already exists.
139+
*
140+
* @param tableName - The name of the table to upsert data into.
141+
* @param variables - The data object to upsert. The keys should correspond to the column names.
142+
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
143+
* @beta
144+
*/
145+
public upsert<GraphQlResponse, Variables extends object>(
146+
tableName: string,
147+
variables: Variables,
148+
): Promise<ExecuteGraphqlResponse<GraphQlResponse>> {
149+
return this.client.upsert(tableName, variables);
150+
}
151+
152+
/**
153+
* Insert multiple rows into the specified table, or update them if they already exist.
154+
*
155+
* @param tableName - The name of the table to upsert data into.
156+
* @param variables - An array of data objects to upsert. Each object's keys should correspond to the column names.
157+
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
158+
* @beta
159+
*/
160+
public upsertMany<GraphQlResponse, Variables extends Array<unknown>>(
161+
tableName: string,
162+
variables: Variables,
163+
): Promise<ExecuteGraphqlResponse<GraphQlResponse>> {
164+
return this.client.upsertMany(tableName, variables);
165+
}
106166
}

0 commit comments

Comments
 (0)