@@ -67,6 +67,8 @@ const EXECUTE_GRAPH_QL_READ_ENDPOINT = 'executeGraphqlRead';
6767const IMPERSONATE_QUERY_ENDPOINT = 'impersonateQuery' ;
6868const IMPERSONATE_MUTATION_ENDPOINT = 'impersonateMutation' ;
6969
70+ /** @internal The maximum number of items allowed in the @allow directive's maxCount argument. */
71+ export const ALLOW_DIRECTIVE_MAX_COUNT = 10_000 ;
7072
7173function getHeaders ( isUsingGen : boolean ) : { [ key : string ] : string } {
7274 const headerValue = {
@@ -100,6 +102,10 @@ interface ConnectorsUrlParams extends ServicesUrlParams {
100102 connectorId : string ;
101103}
102104
105+ interface FieldNode {
106+ children : Map < string , FieldNode > ;
107+ }
108+
103109/**
104110 * Class that facilitates sending requests to the Firebase Data Connect backend API.
105111 *
@@ -438,59 +444,22 @@ export class DataConnectApiClient {
438444 }
439445
440446 /**
441- * Converts JSON data into a GraphQL literal string.
442- * Handles nested objects, arrays, strings, numbers, and booleans.
443- * Ensures strings are properly escaped.
447+ * Generates both capitalized and camel-cased variations of a table name.
448+ * Capitalization matches the schema types, and camel-case matches mutations.
444449 */
445- private objectToString ( data : unknown ) : string {
446- if ( typeof data === 'string' ) {
447- return JSON . stringify ( data ) ;
450+ private getTableNames ( tableName : string ) : { capitalized : string ; camelCase : string } {
451+ if ( ! tableName || tableName . length === 0 ) {
452+ return { capitalized : tableName , camelCase : tableName } ;
448453 }
449- if ( typeof data === 'number' || typeof data === 'boolean' || data === null ) {
450- return String ( data ) ;
451- }
452- if ( validator . isArray ( data ) ) {
453- const elements = data . map ( item => this . objectToString ( item ) ) . join ( ', ' ) ;
454- return `[${ elements } ]` ;
455- }
456- if ( typeof data === 'object' && data !== null ) {
457- // Filter out properties where the value is undefined BEFORE mapping
458- const kvPairs = Object . entries ( data )
459- . filter ( ( [ , val ] ) => val !== undefined )
460- . map ( ( [ key , val ] ) => {
461- // GraphQL object keys are typically unquoted.
462- return `${ key } : ${ this . objectToString ( val ) } ` ;
463- } ) ;
464-
465- if ( kvPairs . length === 0 ) {
466- return '{}' ; // Represent an object with no defined properties as {}
467- }
468- return `{ ${ kvPairs . join ( ', ' ) } }` ;
469- }
470-
471- // If value is undefined (and not an object property, which is handled above,
472- // e.g., if objectToString(undefined) is called directly or for an array element)
473- // it should be represented as 'null'.
474- if ( typeof data === 'undefined' ) {
475- return 'null' ;
476- }
477-
478- // Fallback for any other types (e.g., Symbol, BigInt - though less common in GQL contexts)
479- // Consider how these should be handled or if an error should be thrown.
480- // For now, simple string conversion.
481- return String ( data ) ;
454+ const capitalized = tableName . charAt ( 0 ) . toUpperCase ( ) + tableName . slice ( 1 ) ;
455+ const camelCase = tableName . charAt ( 0 ) . toLowerCase ( ) + tableName . slice ( 1 ) ;
456+ return { capitalized, camelCase } ;
482457 }
483458
484- private formatTableName ( tableName : string ) : string {
485- // Format tableName: first character to lowercase
486- if ( tableName && tableName . length > 0 ) {
487- return tableName . charAt ( 0 ) . toLowerCase ( ) + tableName . slice ( 1 ) ;
488- }
489- return tableName ;
490- }
459+
491460
492461 private handleBulkImportErrors ( err : FirebaseDataConnectError ) : never {
493- if ( err . code === `data-connect/${ DATA_CONNECT_ERROR_CODE_MAPPING . QUERY_ERROR } ` ) {
462+ if ( err . code === `data-connect/${ DATA_CONNECT_ERROR_CODE_MAPPING . QUERY_ERROR } ` ) {
494463 throw new FirebaseDataConnectError ( {
495464 code : DATA_CONNECT_ERROR_CODE_MAPPING . QUERY_ERROR ,
496465 message : `${ err . message } . Make sure that your table name passed in matches the type name in your `
@@ -508,39 +477,7 @@ export class DataConnectApiClient {
508477 tableName : string ,
509478 data : Variables ,
510479 ) : Promise < ExecuteGraphqlResponse < GraphQlResponse > > {
511- if ( ! validator . isNonEmptyString ( tableName ) ) {
512- throw new FirebaseDataConnectError ( {
513- code : DATA_CONNECT_ERROR_CODE_MAPPING . INVALID_ARGUMENT ,
514- message : '`tableName` must be a non-empty string.'
515- } ) ;
516- }
517- if ( validator . isArray ( data ) ) {
518- throw new FirebaseDataConnectError ( {
519- code : DATA_CONNECT_ERROR_CODE_MAPPING . INVALID_ARGUMENT ,
520- message : '`data` must be an object, not an array, for single insert. For arrays, please use '
521- + '`insertMany` function.'
522- } ) ;
523- }
524- if ( ! validator . isNonNullObject ( data ) ) {
525- throw new FirebaseDataConnectError ( {
526- code : DATA_CONNECT_ERROR_CODE_MAPPING . INVALID_ARGUMENT ,
527- message : '`data` must be a non-null object.'
528- } ) ;
529- }
530-
531- try {
532- tableName = this . formatTableName ( tableName ) ;
533- const gqlDataString = this . objectToString ( data ) ;
534- const mutation = `mutation { ${ tableName } _insert(data: ${ gqlDataString } ) }` ;
535- // Use internal executeGraphql
536- return this . executeGraphql < GraphQlResponse , Variables > ( mutation ) . catch ( this . handleBulkImportErrors ) ;
537- } catch ( e : any ) {
538- throw new FirebaseDataConnectError ( {
539- code : DATA_CONNECT_ERROR_CODE_MAPPING . INTERNAL ,
540- message : `Failed to construct insert mutation: ${ e . message } ` ,
541- cause : e ,
542- } ) ;
543- }
480+ return this . executeSingleMutation < GraphQlResponse , Variables > ( tableName , data , 'insert' ) ;
544481 }
545482
546483 /**
@@ -550,32 +487,7 @@ export class DataConnectApiClient {
550487 tableName : string ,
551488 data : Variables ,
552489 ) : Promise < ExecuteGraphqlResponse < GraphQlResponse > > {
553- if ( ! validator . isNonEmptyString ( tableName ) ) {
554- throw new FirebaseDataConnectError ( {
555- code : DATA_CONNECT_ERROR_CODE_MAPPING . INVALID_ARGUMENT ,
556- message : '`tableName` must be a non-empty string.'
557- } ) ;
558- }
559- if ( ! validator . isNonEmptyArray ( data ) ) {
560- throw new FirebaseDataConnectError ( {
561- code : DATA_CONNECT_ERROR_CODE_MAPPING . INVALID_ARGUMENT ,
562- message : '`data` must be a non-empty array for insertMany.' ,
563- } ) ;
564- }
565-
566- try {
567- tableName = this . formatTableName ( tableName ) ;
568- const gqlDataString = this . objectToString ( data ) ;
569- const mutation = `mutation { ${ tableName } _insertMany(data: ${ gqlDataString } ) }` ;
570- // Use internal executeGraphql
571- return this . executeGraphql < GraphQlResponse , Variables > ( mutation ) . catch ( this . handleBulkImportErrors ) ;
572- } catch ( e : any ) {
573- throw new FirebaseDataConnectError ( {
574- code : DATA_CONNECT_ERROR_CODE_MAPPING . INTERNAL ,
575- message : `Failed to construct insertMany mutation: ${ e . message } ` ,
576- cause : e ,
577- } ) ;
578- }
490+ return this . executeBulkMutation < GraphQlResponse , Variables > ( tableName , data , 'insertMany' ) ;
579491 }
580492
581493 /**
@@ -584,6 +496,24 @@ export class DataConnectApiClient {
584496 public async upsert < GraphQlResponse , Variables extends object > (
585497 tableName : string ,
586498 data : Variables ,
499+ ) : Promise < ExecuteGraphqlResponse < GraphQlResponse > > {
500+ return this . executeSingleMutation < GraphQlResponse , Variables > ( tableName , data , 'upsert' ) ;
501+ }
502+
503+ /**
504+ * Insert multiple rows into the specified table, or update them if they already exist.
505+ */
506+ public async upsertMany < GraphQlResponse , Variables extends Array < unknown > > (
507+ tableName : string ,
508+ data : Variables ,
509+ ) : Promise < ExecuteGraphqlResponse < GraphQlResponse > > {
510+ return this . executeBulkMutation < GraphQlResponse , Variables > ( tableName , data , 'upsertMany' ) ;
511+ }
512+
513+ private async executeSingleMutation < GraphQlResponse , Variables extends object > (
514+ tableName : string ,
515+ data : Variables ,
516+ operationType : 'insert' | 'upsert'
587517 ) : Promise < ExecuteGraphqlResponse < GraphQlResponse > > {
588518 if ( ! validator . isNonEmptyString ( tableName ) ) {
589519 throw new FirebaseDataConnectError ( {
@@ -594,8 +524,8 @@ export class DataConnectApiClient {
594524 if ( validator . isArray ( data ) ) {
595525 throw new FirebaseDataConnectError ( {
596526 code : DATA_CONNECT_ERROR_CODE_MAPPING . INVALID_ARGUMENT ,
597- message : '` data` must be an object, not an array, for single upsert. For arrays, please use '
598- + '`upsertMany ` function.'
527+ message : `\` data\ ` must be an object, not an array, for single ${ operationType } .\
528+ For arrays, please use \` ${ operationType } Many\ ` function.`
599529 } ) ;
600530 }
601531 if ( ! validator . isNonNullObject ( data ) ) {
@@ -606,26 +536,28 @@ export class DataConnectApiClient {
606536 }
607537
608538 try {
609- tableName = this . formatTableName ( tableName ) ;
610- const gqlDataString = this . objectToString ( data ) ;
611- const mutation = `mutation { ${ tableName } _upsert(data: ${ gqlDataString } ) }` ;
612- // Use internal executeGraphql
613- return this . executeGraphql < GraphQlResponse , Variables > ( mutation ) . catch ( this . handleBulkImportErrors ) ;
539+ const { capitalized, camelCase } = this . getTableNames ( tableName ) ;
540+ const keys = getFieldsString ( data ) ;
541+ const mutation =
542+ `mutation($data: ${ capitalized } _Data! @allow(fields: "${ keys } ")) {
543+ ${ camelCase } _${ operationType } (data: $data)
544+ }` ;
545+
546+ return this . executeGraphql < GraphQlResponse , { data : Variables } > ( mutation , { variables : { data } } )
547+ . catch ( this . handleBulkImportErrors ) ;
614548 } catch ( e : any ) {
615549 throw new FirebaseDataConnectError ( {
616550 code : DATA_CONNECT_ERROR_CODE_MAPPING . INTERNAL ,
617- message : `Failed to construct upsert mutation: ${ e . message } ` ,
551+ message : `Failed to construct ${ operationType } mutation: ${ e . message } ` ,
618552 cause : e ,
619553 } ) ;
620554 }
621555 }
622556
623- /**
624- * Insert multiple rows into the specified table, or update them if they already exist.
625- */
626- public async upsertMany < GraphQlResponse , Variables extends Array < unknown > > (
557+ private async executeBulkMutation < GraphQlResponse , Variables extends Array < unknown > > (
627558 tableName : string ,
628559 data : Variables ,
560+ operationType : 'insertMany' | 'upsertMany'
629561 ) : Promise < ExecuteGraphqlResponse < GraphQlResponse > > {
630562 if ( ! validator . isNonEmptyString ( tableName ) ) {
631563 throw new FirebaseDataConnectError ( {
@@ -636,20 +568,30 @@ export class DataConnectApiClient {
636568 if ( ! validator . isNonEmptyArray ( data ) ) {
637569 throw new FirebaseDataConnectError ( {
638570 code : DATA_CONNECT_ERROR_CODE_MAPPING . INVALID_ARGUMENT ,
639- message : '`data` must be a non-empty array for upsertMany.'
571+ message : `\`data\` must be a non-empty array for ${ operationType } .`
572+ } ) ;
573+ }
574+ if ( data . length > ALLOW_DIRECTIVE_MAX_COUNT ) {
575+ throw new FirebaseDataConnectError ( {
576+ code : DATA_CONNECT_ERROR_CODE_MAPPING . INVALID_ARGUMENT ,
577+ message : `\`data\` array exceeds the maximum limit of ${ ALLOW_DIRECTIVE_MAX_COUNT } items.`
640578 } ) ;
641579 }
642580
643581 try {
644- tableName = this . formatTableName ( tableName ) ;
645- const gqlDataString = this . objectToString ( data ) ;
646- const mutation = `mutation { ${ tableName } _upsertMany(data: ${ gqlDataString } ) }` ;
647- // Use internal executeGraphql
648- return this . executeGraphql < GraphQlResponse , Variables > ( mutation ) . catch ( this . handleBulkImportErrors ) ;
582+ const { capitalized, camelCase } = this . getTableNames ( tableName ) ;
583+ const keys = getFieldsString ( data ) ;
584+ const mutation =
585+ `mutation($data: [${ capitalized } _Data!]! @allow(fields: "${ keys } ", maxCount: ${ ALLOW_DIRECTIVE_MAX_COUNT } )) {
586+ ${ camelCase } _${ operationType } (data: $data)
587+ }` ;
588+
589+ return this . executeGraphql < GraphQlResponse , { data : Variables } > ( mutation , { variables : { data } } )
590+ . catch ( this . handleBulkImportErrors ) ;
649591 } catch ( e : any ) {
650592 throw new FirebaseDataConnectError ( {
651593 code : DATA_CONNECT_ERROR_CODE_MAPPING . INTERNAL ,
652- message : `Failed to construct upsertMany mutation: ${ e . message } ` ,
594+ message : `Failed to construct ${ operationType } mutation: ${ e . message } ` ,
653595 cause : e ,
654596 } ) ;
655597 }
@@ -689,3 +631,60 @@ interface ServerError {
689631 message ?: string ;
690632 status ?: string ;
691633}
634+
635+ /**
636+ * Extracts property keys from an object or array of objects as a space-separated string,
637+ * including recursively nested object/array fields for the `@allow(fields: ...)` directive.
638+ * Leverages a hierarchical tree to deduplicate and merge fields.
639+ *
640+ * @internal
641+ */
642+ export function getFieldsString ( data : unknown ) : string {
643+ const root : FieldNode = { children : new Map ( ) } ;
644+ mergeFieldsIntoTree ( data , root ) ;
645+ return serializeFieldNode ( root ) ;
646+ }
647+
648+ function mergeFieldsIntoTree ( data : unknown , node : FieldNode , visited : Set < unknown > = new Set < unknown > ( ) ) : void {
649+ if ( validator . isArray ( data ) ) {
650+ data . forEach ( ( item ) => mergeFieldsIntoTree ( item , node , visited ) ) ;
651+ return ;
652+ }
653+ if ( ! validator . isNonNullObject ( data ) || data instanceof Date ) {
654+ return ;
655+ }
656+ if ( visited . has ( data ) ) {
657+ throw new Error ( 'Circular reference detected in input.' ) ;
658+ }
659+ visited . add ( data ) ;
660+ const record = data as Record < string , unknown > ;
661+ for ( const [ key , val ] of Object . entries ( record ) ) {
662+ if ( val === undefined ) {
663+ continue ;
664+ }
665+ let childNode = node . children . get ( key ) ;
666+ if ( ! childNode ) {
667+ childNode = { children : new Map ( ) } ;
668+ node . children . set ( key , childNode ) ;
669+ }
670+ if ( key . includes ( '_on_' ) ) {
671+ mergeFieldsIntoTree ( val , childNode , visited ) ;
672+ }
673+ }
674+ visited . delete ( data ) ;
675+ }
676+
677+ function serializeFieldNode ( node : FieldNode ) : string {
678+ const parts : string [ ] = [ ] ;
679+ const sortedKeys = Array . from ( node . children . keys ( ) ) . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
680+ for ( const key of sortedKeys ) {
681+ const childNode = node . children . get ( key ) ! ;
682+ if ( childNode . children . size > 0 ) {
683+ const nestedString = serializeFieldNode ( childNode ) ;
684+ parts . push ( `${ key } { ${ nestedString } }` ) ;
685+ } else {
686+ parts . push ( key ) ;
687+ }
688+ }
689+ return parts . join ( ' ' ) ;
690+ }
0 commit comments