@@ -198,6 +198,203 @@ export class DataConnectApiClient {
198
198
const message = error . message || `Unknown server error: ${ response . text } ` ;
199
199
return new FirebaseDataConnectError ( code , message ) ;
200
200
}
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
+ }
201
398
}
202
399
203
400
/**
0 commit comments