Skip to content

Commit ee31969

Browse files
fix(dataconnect): Refactor CRUD helpers to use GraphQL variables and @Allow directive (#3182)
* add tests checking for new GQL output for [insert,upsert](many) * change mutation functions to use @Allow * factor out common code after @Allow changes * update tests to expect @Allow * update the @Allow directive for list inputs * initial fix * address reviewer comments * fix style and normalize expected + actual query strings in tests * add fdc to integration test documentation and update integration test artifact ignores * improve getTableNames variable naming for clarity * add 10k limit and nested coalesced field keys for @Allow * de-duplicate code in mutaiton CRUD functions, and refactor tests * update contributing * add maxCount to array bulk insert variables * limit @Allow nesting to _on_ relational fields * update tests * address reviewer comments
1 parent c550a22 commit ee31969

4 files changed

Lines changed: 394 additions & 328 deletions

File tree

.gitignore

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ firebase-admin-*.tgz
2121

2222
docgen/markdown/
2323

24-
# Dataconnect integration test artifacts should not be checked in
24+
# Integration test artifacts should not be checked in
25+
**/database-debug.log
26+
**/firestore-debug.log
2527
test/integration/dataconnect/dataconnect/.dataconnect
26-
test/integration/dataconnect/*.log
28+
**/dataconnect-debug.log
29+
**/pglite-debug.log
30+

CONTRIBUTING.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,20 @@ And then:
150150
'npx mocha \"test/integration/{auth,database,firestore}.spec.ts\" --slow 5000 --timeout 20000 --require ts-node/register'
151151
```
152152

153-
Currently, only the Auth, Database, and Firestore test suites work. Some test
154-
cases will be automatically skipped due to lack of emulator support. The section
155-
below covers how to run the full test suite against an actual Firebase project.
153+
Currently, only the Auth, Database, and Firestore test suites work. Some test cases
154+
will be automatically skipped due to lack of emulator support.
155+
156+
**Note:** You can also run the Data Connect test suite against the emulators using the same command.
157+
However, you must isolate the Data Connect tests by using a configuration file specific to Data Connect
158+
emulator testing:
159+
160+
```bash
161+
firebase emulators:exec \
162+
--project fake-project-id --only dataconnect --config test/integration/dataconnect/firebase.json \
163+
'npx mocha \"test/integration/data-connect.spec.ts\" --slow 5000 --timeout 20000 --require ts-node/register'
164+
```
165+
166+
The section below covers how to run the full test suite against an actual Firebase project.
156167

157168
#### Integration Tests with an actual Firebase project
158169

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

Lines changed: 124 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ const EXECUTE_GRAPH_QL_READ_ENDPOINT = 'executeGraphqlRead';
6767
const IMPERSONATE_QUERY_ENDPOINT = 'impersonateQuery';
6868
const 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

7173
function 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

Comments
 (0)