diff --git a/.gitignore b/.gitignore index 32d4066dfc..e19489c327 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ firebase-admin-*.tgz docgen/markdown/ -# Dataconnect integration test artifacts should not be checked in +# Integration test artifacts should not be checked in +**/database-debug.log +**/firestore-debug.log test/integration/dataconnect/dataconnect/.dataconnect -test/integration/dataconnect/*.log +**/dataconnect-debug.log +**/pglite-debug.log + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1c626c611..2b6e250d89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,9 +150,19 @@ And then: 'npx mocha \"test/integration/{auth,database,firestore}.spec.ts\" --slow 5000 --timeout 20000 --require ts-node/register' ``` -Currently, only the Auth, Database, and Firestore test suites work. Some test -cases will be automatically skipped due to lack of emulator support. The section -below covers how to run the full test suite against an actual Firebase project. +Currently, only the Auth, Database, and Firestore test suites work. Some test cases +will be automatically skipped due to lack of emulator support. + +You can also run the Data Connect test suite against the emulators using the same command, +but with a config file specific to Data Connect emulator testing: + +```bash + firebase emulators:exec \ + --project fake-project-id --only dataconnect --config test/integration/dataconnect/firebase.json \ + 'npx mocha \"test/integration/data-connect.spec.ts\" --slow 5000 --timeout 20000 --require ts-node/register' +``` + +The section below covers how to run the full test suite against an actual Firebase project. #### Integration Tests with an actual Firebase project diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index c6451ab888..2eed0d5dbf 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -438,59 +438,50 @@ export class DataConnectApiClient { } /** - * Converts JSON data into a GraphQL literal string. - * Handles nested objects, arrays, strings, numbers, and booleans. - * Ensures strings are properly escaped. + * Generates both capitalized and camel-cased variations of a table name. + * Capitalization matches the schema types, and camel-case matches mutations. */ - private objectToString(data: unknown): string { - if (typeof data === 'string') { - return JSON.stringify(data); - } - if (typeof data === 'number' || typeof data === 'boolean' || data === null) { - return String(data); - } - if (validator.isArray(data)) { - const elements = data.map(item => this.objectToString(item)).join(', '); - return `[${elements}]`; - } - if (typeof data === 'object' && data !== null) { - // Filter out properties where the value is undefined BEFORE mapping - const kvPairs = Object.entries(data) - .filter(([, val]) => val !== undefined) - .map(([key, val]) => { - // GraphQL object keys are typically unquoted. - return `${key}: ${this.objectToString(val)}`; - }); - - if (kvPairs.length === 0) { - return '{}'; // Represent an object with no defined properties as {} - } - return `{ ${kvPairs.join(', ')} }`; - } - - // If value is undefined (and not an object property, which is handled above, - // e.g., if objectToString(undefined) is called directly or for an array element) - // it should be represented as 'null'. - if (typeof data === 'undefined') { - return 'null'; + private getTableNames(tableName: string): { capitalized: string; camelCase: string } { + if (!tableName || tableName.length === 0) { + return { capitalized: tableName, camelCase: tableName }; } + const capitalized = tableName.charAt(0).toUpperCase() + tableName.slice(1); + const camelCase = tableName.charAt(0).toLowerCase() + tableName.slice(1); + return { capitalized, camelCase }; + } - // Fallback for any other types (e.g., Symbol, BigInt - though less common in GQL contexts) - // Consider how these should be handled or if an error should be thrown. - // For now, simple string conversion. - return String(data); + /** + * Extracts all defined property keys from an object as a space-separated string. + * Used to build the `@allow(fields: ...)` mutation directive for single operations. + */ + private getObjectKeys(data: Record | object): string { + return Object.keys(data) + .filter(key => (data as Record)[key] !== undefined) + .join(' '); } - private formatTableName(tableName: string): string { - // Format tableName: first character to lowercase - if (tableName && tableName.length > 0) { - return tableName.charAt(0).toLowerCase() + tableName.slice(1); + /** + * Extracts the union of all defined property keys across an array of objects + * as a space-separated string. Used to build the `@allow(fields: ...)` mutation + * directive for bulk operations. + */ + private getArrayObjectsKeys(data: Array): string { + const allKeys = new Set(); + for (const element of data) { + if (validator.isNonNullObject(element)) { + const record = element as Record; + Object.keys(record).forEach(key => { + if (record[key] !== undefined) { + allKeys.add(key); + } + }); + } } - return tableName; + return Array.from(allKeys).join(' '); } private handleBulkImportErrors(err: FirebaseDataConnectError): never { - if (err.code === `data-connect/${DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR}`){ + if (err.code === `data-connect/${DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR}`) { throw new FirebaseDataConnectError({ code: DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, message: `${err.message}. Make sure that your table name passed in matches the type name in your ` @@ -529,11 +520,15 @@ export class DataConnectApiClient { } try { - tableName = this.formatTableName(tableName); - const gqlDataString = this.objectToString(data); - const mutation = `mutation { ${tableName}_insert(data: ${gqlDataString}) }`; - // Use internal executeGraphql - return this.executeGraphql(mutation).catch(this.handleBulkImportErrors); + const { capitalized, camelCase } = this.getTableNames(tableName); + const keys = this.getObjectKeys(data); + const mutation = + `mutation($data: ${capitalized}_Data! @allow(fields: "${keys}")) { + ${camelCase}_insert(data: $data) + }`; + + return this.executeGraphql(mutation, { variables: { data } }) + .catch(this.handleBulkImportErrors); } catch (e: any) { throw new FirebaseDataConnectError({ code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, @@ -564,11 +559,15 @@ export class DataConnectApiClient { } try { - tableName = this.formatTableName(tableName); - const gqlDataString = this.objectToString(data); - const mutation = `mutation { ${tableName}_insertMany(data: ${gqlDataString}) }`; - // Use internal executeGraphql - return this.executeGraphql(mutation).catch(this.handleBulkImportErrors); + const { capitalized, camelCase } = this.getTableNames(tableName); + const keys = this.getArrayObjectsKeys(data); + const mutation = + `mutation($data: [${capitalized}_Data!]! @allow(fields: "${keys}")) { + ${camelCase}_insertMany(data: $data) + }`; + + return this.executeGraphql(mutation, { variables: { data } }) + .catch(this.handleBulkImportErrors); } catch (e: any) { throw new FirebaseDataConnectError({ code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, @@ -606,11 +605,15 @@ export class DataConnectApiClient { } try { - tableName = this.formatTableName(tableName); - const gqlDataString = this.objectToString(data); - const mutation = `mutation { ${tableName}_upsert(data: ${gqlDataString}) }`; - // Use internal executeGraphql - return this.executeGraphql(mutation).catch(this.handleBulkImportErrors); + const { capitalized, camelCase } = this.getTableNames(tableName); + const keys = this.getObjectKeys(data); + const mutation = + `mutation($data: ${capitalized}_Data! @allow(fields: "${keys}")) { + ${camelCase}_upsert(data: $data) + }`; + + return this.executeGraphql(mutation, { variables: { data } }) + .catch(this.handleBulkImportErrors); } catch (e: any) { throw new FirebaseDataConnectError({ code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, @@ -641,11 +644,15 @@ export class DataConnectApiClient { } try { - tableName = this.formatTableName(tableName); - const gqlDataString = this.objectToString(data); - const mutation = `mutation { ${tableName}_upsertMany(data: ${gqlDataString}) }`; - // Use internal executeGraphql - return this.executeGraphql(mutation).catch(this.handleBulkImportErrors); + const { capitalized, camelCase } = this.getTableNames(tableName); + const keys = this.getArrayObjectsKeys(data); + const mutation = + `mutation($data: [${capitalized}_Data!]! @allow(fields: "${keys}")) { + ${camelCase}_upsertMany(data: $data) + }`; + + return this.executeGraphql(mutation, { variables: { data } }) + .catch(this.handleBulkImportErrors); } catch (e: any) { throw new FirebaseDataConnectError({ code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, diff --git a/test/unit/data-connect/data-connect-api-client-internal.spec.ts b/test/unit/data-connect/data-connect-api-client-internal.spec.ts index 7595928f05..3d347bc9c9 100644 --- a/test/unit/data-connect/data-connect-api-client-internal.spec.ts +++ b/test/unit/data-connect/data-connect-api-client-internal.spec.ts @@ -699,11 +699,30 @@ describe('DataConnectApiClient CRUD helpers', () => { // Helper function to normalize GraphQL strings const normalizeGraphQLString = (str: string): string => { return str - .replace(/\s*\n\s*/g, '\n') // Remove leading/trailing whitespace around newlines - .replace(/\s+/g, ' ') // Replace multiple spaces with a single space + .replace(/\n/g, '') // Remove newlines + .replace(/\s+/g, ' ') // Replace multiple spaces with a single space .trim(); // Remove leading/trailing whitespace from the whole string }; + /** + * Helper function to normalize and validate the executeGraphql calls. Importantly, + * normalizes the actual input and the expected input to account for whitespace + * diffs. + */ + function expectNormalizedExecuteGraphqlCall( + expectedQuery: string, + expectedVariables: Record + ): void { + expect(executeGraphqlStub).to.have.been.calledOnce; + const call = executeGraphqlStub.getCall(0); + expect(normalizeGraphQLString(call.args[0])).to.equal(normalizeGraphQLString(expectedQuery)); + expect(call.args[1]).to.deep.equal(expectedVariables); + } + + const capitalize = (str: string): string => { + return str.charAt(0).toUpperCase() + str.slice(1); + }; + beforeEach(() => { mockApp = mocks.appWithOptions(mockOptions); apiClient = new DataConnectApiClient(connectorConfig, mockApp); @@ -721,53 +740,45 @@ describe('DataConnectApiClient CRUD helpers', () => { // --- INSERT TESTS --- describe('insert()', () => { tableNames.forEach((tableName, index) => { - const expectedMutation = `mutation { ${formatedTableNames[index]}_insert(data: { name: "a" }) }`; + const capitalizedTable = capitalize(formatedTableNames[index]); + const expectedMutation = + `mutation($data: ${capitalizedTable}_Data! @allow(fields: "name")) { + ${formatedTableNames[index]}_insert(data: $data) + }`; it(`should use the formatted tableName in the gql query: "${tableName}" as "${formatedTableNames[index]}"`, async () => { await apiClient.insert(tableName, { name: 'a' }); - await expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: { name: 'a' } } }); }); }); it('should call executeGraphql with the correct mutation for simple data', async () => { const simpleData = { name: 'test', value: 123 }; - const expectedMutation = ` - mutation { - ${formatedTableName}_insert(data: { - name: "test", - value: 123 - }) - }`; + const expectedMutation = + `mutation($data: TestTable_Data! @allow(fields: "name value")) { + ${formatedTableName}_insert(data: $data) + }`; await apiClient.insert(tableName, simpleData); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: simpleData } }); }); it('should call executeGraphql with the correct mutation for complex data', async () => { const complexData = { id: 'abc', active: true, scores: [10, 20], info: { nested: 'yes/no "quote" \\slash\\' } }; - const expectedMutation = ` - mutation { - ${formatedTableName}_insert(data: { - id: "abc", active: true, scores: [10, 20], - info: { nested: "yes/no \\"quote\\" \\\\slash\\\\" } - }) - }`; + const expectedMutation = + `mutation($data: TestTable_Data! @allow(fields: "id active scores info")) { + ${formatedTableName}_insert(data: $data) + }`; await apiClient.insert(tableName, complexData); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: complexData } }); }); it('should call executeGraphql with the correct mutation for undefined and null values', async () => { - const expectedMutation = ` - mutation { - ${formatedTableName}_insert(data: { - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] - }) - }`; + const expectedMutation = + `mutation($data: TestTable_Data! @allow(fields: "genre title ratings director extras")) { + ${formatedTableName}_insert(data: $data) + }`; await apiClient.insert(tableName, dataWithUndefined); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: dataWithUndefined } }); }); it('should throw FirebaseDataConnectError for invalid tableName', async () => { @@ -795,26 +806,44 @@ describe('DataConnectApiClient CRUD helpers', () => { expect(err.cause).to.equal(expectedQueryError); } }); + + it('should call executeGraphql with variables and @allow directive for enums and other fields', async () => { + const data = { id: 'key1', name: 'Fred', status: 'ACTIVE' }; + const expectedMutation = ` + mutation($data: TestTable_Data! @allow(fields: "id name status")) { + testTable_insert(data: $data) + }`; + const expectedOptions = { + variables: { data } + }; + await apiClient.insert(tableName, data); + expectNormalizedExecuteGraphqlCall(expectedMutation, expectedOptions); + }); }); // --- INSERT MANY TESTS --- describe('insertMany()', () => { tableNames.forEach((tableName, index) => { - const expectedMutation = `mutation { ${formatedTableNames[index]}_insertMany(data: [{ name: "a" }]) }`; + const capitalizedTable = capitalize(formatedTableNames[index]); + const expectedMutation = ` + mutation($data: [${capitalizedTable}_Data!]! @allow(fields: "name")) { + ${formatedTableNames[index]}_insertMany(data: $data) + }`; it(`should use the formatted tableName in the gql query: "${tableName}" as "${formatedTableNames[index]}"`, async () => { await apiClient.insertMany(tableName, [{ name: 'a' }]); - await expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: [{ name: 'a' }] } }); }); }); it('should call executeGraphql with the correct mutation for simple data array', async () => { const simpleDataArray = [{ name: 'test1' }, { name: 'test2', value: 456 }]; const expectedMutation = ` - mutation { - ${formatedTableName}_insertMany(data: [{ name: "test1" }, { name: "test2", value: 456 }]) }`; + mutation($data: [TestTable_Data!]! @allow(fields: "name value")) { + ${formatedTableName}_insertMany(data: $data) + }`; await apiClient.insertMany(tableName, simpleDataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: simpleDataArray } }); }); it('should call executeGraphql with the correct mutation for complex data array', async () => { @@ -823,38 +852,24 @@ describe('DataConnectApiClient CRUD helpers', () => { { id: 'b', scores: [1, 2], info: { nested: 'n2/\\' } } ]; const expectedMutation = ` - mutation { - ${formatedTableName}_insertMany(data: - [{ id: "a", active: true, info: { nested: "n1 \\"quote\\"" } }, { id: "b", scores: [1, 2], - info: { nested: "n2/\\\\" } }]) }`; + mutation($data: [TestTable_Data!]! @allow(fields: "id active info scores")) { + ${formatedTableName}_insertMany(data: $data) + }`; await apiClient.insertMany(tableName, complexDataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: complexDataArray } }); }); it('should call executeGraphql with the correct mutation for undefined and null', async () => { const dataArray = [ dataWithUndefined, dataWithUndefined - ] + ]; const expectedMutation = ` - mutation { - ${formatedTableName}_insertMany(data: [{ - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] - }, - { - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] - }]) - }`; + mutation($data: [TestTable_Data!]! @allow(fields: "genre title ratings director extras")) { + ${formatedTableName}_insertMany(data: $data) + }`; await apiClient.insertMany(tableName, dataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: dataArray } }); }); it('should throw FirebaseDataConnectError for invalid tableName', async () => { @@ -887,48 +902,66 @@ describe('DataConnectApiClient CRUD helpers', () => { expect(err.cause).to.equal(expectedQueryError); } }); + + it('should call executeGraphql with variables and @allow directive for enums and other fields', async () => { + const data = [ + { id: 'key1', name: 'Fred', status: 'ACTIVE' }, + { id: 'key2', name: 'Bob', tags: ['cool'] } + ]; + const expectedMutation = ` + mutation($data: [TestTable_Data!]! @allow(fields: "id name status tags")) { + testTable_insertMany(data: $data) + }`; + const expectedOptions = { + variables: { data } + }; + await apiClient.insertMany(tableName, data); + expectNormalizedExecuteGraphqlCall(expectedMutation, expectedOptions); + }); }); // --- UPSERT TESTS --- describe('upsert()', () => { tableNames.forEach((tableName, index) => { - const expectedMutation = `mutation { ${formatedTableNames[index]}_upsert(data: { name: "a" }) }`; + const capitalizedTable = capitalize(formatedTableNames[index]); + const expectedMutation = ` + mutation($data: ${capitalizedTable}_Data! @allow(fields: "name")) { + ${formatedTableNames[index]}_upsert(data: $data) + }`; it(`should use the formatted tableName in the gql query: "${tableName}" as "${formatedTableNames[index]}"`, async () => { await apiClient.upsert(tableName, { name: 'a' }); - await expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: { name: 'a' } } }); }); }); it('should call executeGraphql with the correct mutation for simple data', async () => { const simpleData = { id: 'key1', value: 'updated' }; - const expectedMutation = `mutation { ${formatedTableName}_upsert(data: { id: "key1", value: "updated" }) }`; + const expectedMutation = ` + mutation($data: TestTable_Data! @allow(fields: "id value")) { + ${formatedTableName}_upsert(data: $data) + }`; await apiClient.upsert(tableName, simpleData); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(expectedMutation); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: simpleData } }); }); it('should call executeGraphql with the correct mutation for complex data', async () => { const complexData = { id: 'key2', active: false, items: [1, null], detail: { status: 'done/\\' } }; const expectedMutation = ` - mutation { ${formatedTableName}_upsert(data: - { id: "key2", active: false, items: [1, null], detail: { status: "done/\\\\" } }) }`; + mutation($data: TestTable_Data! @allow(fields: "id active items detail")) { + ${formatedTableName}_upsert(data: $data) + }`; await apiClient.upsert(tableName, complexData); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: complexData } }); }); it('should call executeGraphql with the correct mutation for undefined and null values', async () => { const expectedMutation = ` - mutation { - ${formatedTableName}_upsert(data: { - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] - }) - }`; + mutation($data: TestTable_Data! @allow(fields: "genre title ratings director extras")) { + ${formatedTableName}_upsert(data: $data) + }`; await apiClient.upsert(tableName, dataWithUndefined); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: dataWithUndefined } }); }); it('should throw FirebaseDataConnectError for invalid tableName', async () => { @@ -956,25 +989,44 @@ describe('DataConnectApiClient CRUD helpers', () => { expect(err.cause).to.equal(expectedQueryError); } }); + + it('should call executeGraphql with variables and @allow directive for enums and other fields', async () => { + const data = { id: 'key1', name: 'Fred', status: 'ACTIVE' }; + const expectedMutation = ` + mutation($data: TestTable_Data! @allow(fields: "id name status")) { + testTable_upsert(data: $data) + }`; + const expectedOptions = { + variables: { data } + }; + await apiClient.upsert(tableName, data); + expectNormalizedExecuteGraphqlCall(expectedMutation, expectedOptions); + }); }); // --- UPSERT MANY TESTS --- describe('upsertMany()', () => { tableNames.forEach((tableName, index) => { - const expectedMutation = `mutation { ${formatedTableNames[index]}_upsertMany(data: [{ name: "a" }]) }`; + const capitalizedTable = capitalize(formatedTableNames[index]); + const expectedMutation = ` + mutation($data: [${capitalizedTable}_Data!]! @allow(fields: "name")) { + ${formatedTableNames[index]}_upsertMany(data: $data) + }`; it(`should use the formatted tableName in the gql query: "${tableName}" as "${formatedTableNames[index]}"`, async () => { await apiClient.upsertMany(tableName, [{ name: 'a' }]); - await expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: [{ name: 'a' }] } }); }); }); it('should call executeGraphql with the correct mutation for simple data array', async () => { const simpleDataArray = [{ id: 'k1' }, { id: 'k2', value: 99 }]; const expectedMutation = ` - mutation { ${formatedTableName}_upsertMany(data: [{ id: "k1" }, { id: "k2", value: 99 }]) }`; + mutation($data: [TestTable_Data!]! @allow(fields: "id value")) { + ${formatedTableName}_upsertMany(data: $data) + }`; await apiClient.upsertMany(tableName, simpleDataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: simpleDataArray } }); }); it('should call executeGraphql with the correct mutation for complex data array', async () => { @@ -983,36 +1035,24 @@ describe('DataConnectApiClient CRUD helpers', () => { { id: 'y', scores: [null, 2] } ]; const expectedMutation = ` - mutation { ${formatedTableName}_upsertMany(data: - [{ id: "x", active: true, info: { nested: "n1/\\\\\\"x" } }, { id: "y", scores: [null, 2] }]) }`; + mutation($data: [TestTable_Data!]! @allow(fields: "id active info scores")) { + ${formatedTableName}_upsertMany(data: $data) + }`; await apiClient.upsertMany(tableName, complexDataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: complexDataArray } }); }); it('should call executeGraphql with the correct mutation for undefined and null', async () => { const dataArray = [ dataWithUndefined, dataWithUndefined - ] + ]; const expectedMutation = ` - mutation { - ${formatedTableName}_upsertMany(data: [{ - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] - }, - { - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] - }]) - }`; + mutation($data: [TestTable_Data!]! @allow(fields: "genre title ratings director extras")) { + ${formatedTableName}_upsertMany(data: $data) + }`; await apiClient.upsertMany(tableName, dataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: dataArray } }); }); it('should throw FirebaseDataConnectError for invalid tableName', async () => { @@ -1045,51 +1085,67 @@ describe('DataConnectApiClient CRUD helpers', () => { expect(err.cause).to.equal(expectedQueryError); } }); + + it('should call executeGraphql with variables and @allow directive for enums and other fields', async () => { + const data = [ + { id: 'key1', name: 'Fred', status: 'ACTIVE' }, + { id: 'key2', name: 'Bob', tags: ['cool'] } + ]; + const expectedMutation = ` + mutation($data: [TestTable_Data!]! @allow(fields: "id name status tags")) { + testTable_upsertMany(data: $data) + }`; + const expectedOptions = { + variables: { data } + }; + await apiClient.upsertMany(tableName, data); + expectNormalizedExecuteGraphqlCall(expectedMutation, expectedOptions); + }); }); describe('String serialization', () => { - it('should correctly escape special characters in strings during insert', async () => { + it('should correctly handle special characters in strings during insert', async () => { const data = { content: 'Line 1\nLine 2', }; await apiClient.insert(tableName, data); - const callArgs = executeGraphqlStub.firstCall.args[0]; + const callOptions = executeGraphqlStub.firstCall.args[1]; - expect(callArgs).to.include(String.raw`content: "Line 1\nLine 2"`); + expect(callOptions.variables.data.content).to.equal('Line 1\nLine 2'); }); - it('should correctly escape backslash', async () => { + it('should correctly handle backslash', async () => { const data = { content: 'Backslash \\', }; await apiClient.insert(tableName, data); - const callArgs = executeGraphqlStub.firstCall.args[0]; + const callOptions = executeGraphqlStub.firstCall.args[1]; - expect(callArgs).to.include(String.raw`content: "Backslash \\"`); + expect(callOptions.variables.data.content).to.equal('Backslash \\'); }); - it('should correctly escape double quotes', async () => { + it('should correctly handle double quotes', async () => { const data = { content: 'Quote "test"', }; await apiClient.insert(tableName, data); - const callArgs = executeGraphqlStub.firstCall.args[0]; + const callOptions = executeGraphqlStub.firstCall.args[1]; - expect(callArgs).to.include(String.raw`content: "Quote \"test\""`); + expect(callOptions.variables.data.content).to.equal('Quote "test"'); }); - it('should correctly escape tab character', async () => { + it('should correctly handle tab character', async () => { const data = { content: 'Tab\tCharacter', }; await apiClient.insert(tableName, data); - const callArgs = executeGraphqlStub.firstCall.args[0]; + const callOptions = executeGraphqlStub.firstCall.args[1]; - expect(callArgs).to.include(String.raw`content: "Tab\tCharacter"`); + expect(callOptions.variables.data.content).to.equal('Tab\tCharacter'); }); it('should correctly handle emojis', async () => { @@ -1098,9 +1154,10 @@ describe('DataConnectApiClient CRUD helpers', () => { }; await apiClient.insert(tableName, data); - const callArgs = executeGraphqlStub.firstCall.args[0]; + const callOptions = executeGraphqlStub.firstCall.args[1]; - expect(callArgs).to.include('content: "Emoji 😊"'); + expect(callOptions.variables.data.content).to.equal('Emoji 😊'); }); }); }); +