@@ -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/**
0 commit comments