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..78fb603021 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,9 +150,20 @@ 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 you must run only the dataconnect tests, using 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..e5fca90d40 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -67,6 +67,8 @@ const EXECUTE_GRAPH_QL_READ_ENDPOINT = 'executeGraphqlRead'; const IMPERSONATE_QUERY_ENDPOINT = 'impersonateQuery'; const IMPERSONATE_MUTATION_ENDPOINT = 'impersonateMutation'; +/** @internal The maximum number of items allowed in the @allow directive's maxCount argument. */ +export const ALLOW_DIRECTIVE_MAX_COUNT = 10_000; function getHeaders(isUsingGen: boolean): { [key: string]: string } { const headerValue = { @@ -100,6 +102,10 @@ interface ConnectorsUrlParams extends ServicesUrlParams { connectorId: string; } +interface FieldNode { + children: Map; +} + /** * Class that facilitates sending requests to the Firebase Data Connect backend API. * @@ -438,59 +444,67 @@ 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); + 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 }; + } + + /** + * Extracts property keys from an object or array of objects as a space-separated string, + * including recursively nested object/array fields for the `@allow(fields: ...)` directive. + * Leverages a hierarchical tree to deduplicate and merge fields. + */ + private getFieldsString(data: unknown): string { + const root: FieldNode = { children: new Map() }; + this.mergeFieldsIntoTree(data, root); + return this.serializeFieldNode(root); + } + + private mergeFieldsIntoTree(data: unknown, node: FieldNode): void { 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 {} + for (const item of data) { + this.mergeFieldsIntoTree(item, node); + } + } else if (validator.isNonNullObject(data) && !(data instanceof Date)) { + const record = data as Record; + for (const [key, val] of Object.entries(record)) { + if (val === undefined) { + continue; + } + let childNode = node.children.get(key); + if (!childNode) { + childNode = { children: new Map() }; + node.children.set(key, childNode); + } + this.mergeFieldsIntoTree(val, childNode); } - 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'; } - - // 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); } - private formatTableName(tableName: string): string { - // Format tableName: first character to lowercase - if (tableName && tableName.length > 0) { - return tableName.charAt(0).toLowerCase() + tableName.slice(1); + private serializeFieldNode(node: FieldNode): string { + const parts: string[] = []; + const sortedKeys = Array.from(node.children.keys()).sort((a, b) => a.localeCompare(b)); + for (const key of sortedKeys) { + const childNode = node.children.get(key)!; + if (childNode.children.size > 0) { + const nestedString = this.serializeFieldNode(childNode); + parts.push(`${key} { ${nestedString} }`); + } else { + parts.push(key); + } } - return tableName; + return parts.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 ` @@ -508,39 +522,7 @@ export class DataConnectApiClient { tableName: string, data: Variables, ): Promise> { - if (!validator.isNonEmptyString(tableName)) { - throw new FirebaseDataConnectError({ - code: DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - message: '`tableName` must be a non-empty string.' - }); - } - if (validator.isArray(data)) { - throw new FirebaseDataConnectError({ - code: DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - message: '`data` must be an object, not an array, for single insert. For arrays, please use ' - + '`insertMany` function.' - }); - } - if (!validator.isNonNullObject(data)) { - throw new FirebaseDataConnectError({ - code: DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - message: '`data` must be a non-null object.' - }); - } - - 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); - } catch (e: any) { - throw new FirebaseDataConnectError({ - code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, - message: `Failed to construct insert mutation: ${e.message}`, - cause: e, - }); - } + return this.executeSingleMutation(tableName, data, 'insert'); } /** @@ -550,32 +532,7 @@ export class DataConnectApiClient { tableName: string, data: Variables, ): Promise> { - if (!validator.isNonEmptyString(tableName)) { - throw new FirebaseDataConnectError({ - code: DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - message: '`tableName` must be a non-empty string.' - }); - } - if (!validator.isNonEmptyArray(data)) { - throw new FirebaseDataConnectError({ - code: DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - message: '`data` must be a non-empty array for insertMany.', - }); - } - - 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); - } catch (e: any) { - throw new FirebaseDataConnectError({ - code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, - message: `Failed to construct insertMany mutation: ${e.message}`, - cause: e, - }); - } + return this.executeBulkMutation(tableName, data, 'insertMany'); } /** @@ -584,6 +541,24 @@ export class DataConnectApiClient { public async upsert( tableName: string, data: Variables, + ): Promise> { + return this.executeSingleMutation(tableName, data, 'upsert'); + } + + /** + * Insert multiple rows into the specified table, or update them if they already exist. + */ + public async upsertMany>( + tableName: string, + data: Variables, + ): Promise> { + return this.executeBulkMutation(tableName, data, 'upsertMany'); + } + + private async executeSingleMutation( + tableName: string, + data: Variables, + operationType: 'insert' | 'upsert' ): Promise> { if (!validator.isNonEmptyString(tableName)) { throw new FirebaseDataConnectError({ @@ -594,8 +569,8 @@ export class DataConnectApiClient { if (validator.isArray(data)) { throw new FirebaseDataConnectError({ code: DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - message: '`data` must be an object, not an array, for single upsert. For arrays, please use ' - + '`upsertMany` function.' + message: `\`data\` must be an object, not an array, for single ${operationType}.\ + For arrays, please use \`${operationType}Many\` function.` }); } if (!validator.isNonNullObject(data)) { @@ -606,26 +581,28 @@ 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.getFieldsString(data); + const mutation = + `mutation($data: ${capitalized}_Data! @allow(fields: "${keys}")) { + ${camelCase}_${operationType}(data: $data) + }`; + + return this.executeGraphql(mutation, { variables: { data } }) + .catch(this.handleBulkImportErrors); } catch (e: any) { throw new FirebaseDataConnectError({ code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, - message: `Failed to construct upsert mutation: ${e.message}`, + message: `Failed to construct ${operationType} mutation: ${e.message}`, cause: e, }); } } - /** - * Insert multiple rows into the specified table, or update them if they already exist. - */ - public async upsertMany>( + private async executeBulkMutation>( tableName: string, data: Variables, + operationType: 'insertMany' | 'upsertMany' ): Promise> { if (!validator.isNonEmptyString(tableName)) { throw new FirebaseDataConnectError({ @@ -636,20 +613,30 @@ export class DataConnectApiClient { if (!validator.isNonEmptyArray(data)) { throw new FirebaseDataConnectError({ code: DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - message: '`data` must be a non-empty array for upsertMany.' + message: `\`data\` must be a non-empty array for ${operationType}.` + }); + } + if (data.length > ALLOW_DIRECTIVE_MAX_COUNT) { + throw new FirebaseDataConnectError({ + code: DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + message: `\`data\` array exceeds the maximum limit of ${ALLOW_DIRECTIVE_MAX_COUNT} items.` }); } 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.getFieldsString(data); + const mutation = + `mutation($data: [${capitalized}_Data!]! @allow(fields: "${keys}", maxCount: ${ALLOW_DIRECTIVE_MAX_COUNT})) { + ${camelCase}_${operationType}(data: $data) + }`; + + return this.executeGraphql(mutation, { variables: { data } }) + .catch(this.handleBulkImportErrors); } catch (e: any) { throw new FirebaseDataConnectError({ code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, - message: `Failed to construct upsertMany mutation: ${e.message}`, + message: `Failed to construct ${operationType} mutation: ${e.message}`, cause: e, }); } 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..62c272982a 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 @@ -24,7 +24,10 @@ import { } from '../../../src/utils/api-request'; import * as utils from '../utils'; import * as mocks from '../../resources/mocks'; -import { DataConnectApiClient } from '../../../src/data-connect/data-connect-api-client-internal'; +import { + ALLOW_DIRECTIVE_MAX_COUNT, + DataConnectApiClient +} from '../../../src/data-connect/data-connect-api-client-internal'; import { FirebaseDataConnectError, DATA_CONNECT_ERROR_CODE_MAPPING, @@ -64,8 +67,8 @@ describe('DataConnectApiClient', () => { }; const noProjectId = 'Failed to determine project ID. Initialize the SDK with service ' - + 'account credentials or set project ID as an app option. Alternatively, set the ' - + 'GOOGLE_CLOUD_PROJECT environment variable.'; + + 'account credentials or set project ID as an app option. Alternatively, set the ' + + 'GOOGLE_CLOUD_PROJECT environment variable.'; const TEST_RESPONSE = { data: { @@ -699,9 +702,29 @@ 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 - .trim(); // Remove leading/trailing whitespace from the whole string + .replace(/\s*\n\s*/g, ' ') // Replace newline and surrounding spaces with a single space + .replace(/\s+/g, ' ') // Collapse multiple spaces to a single space + .replace(/\s*([(){},:"'])\s*/g, '$1') // Remove all spaces surrounding structural characters + .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(() => { @@ -718,56 +741,95 @@ describe('DataConnectApiClient CRUD helpers', () => { return mockApp.delete(); }); + // --- GET FIELDS STRING TESTS --- + describe('getFieldsString()', () => { + it('should extract keys from a simple object sorted alphabetically', () => { + const data = { name: 'test', value: 123 }; + const fields = apiClient['getFieldsString'](data); + expect(fields).to.equal('name value'); + }); + + it('should recursively extract deep nested object fields sorted alphabetically', () => { + const data = { id: 'abc', active: true, scores: [10, 20], info: { nested: 'yes/no "quote" \\slash\\' } }; + const fields = apiClient['getFieldsString'](data); + expect(fields).to.equal('active id info { nested } scores'); + }); + + it('should recursively extract deep nested object/array fields in @allow directive format', () => { + const deepData = { + id: '123', + customerId: 'c1', + total: 100, + tags: ['a', 'b'], + products_on_order: [ + { id: 'p1', name: 'Product 1', price: 9.99, categories: [{ id: 'cat1', name: 'Category 1' }] } + ] + }; + const fields = apiClient['getFieldsString'](deepData); + expect(fields).to.equal('customerId id products_on_order { categories { id name } id name price } tags total'); + }); + + it('should skip undefined fields and handle nulls/empty objects', () => { + const fields = apiClient['getFieldsString'](dataWithUndefined); + expect(fields).to.equal('director extras { a } genre ratings title'); + }); + + it('should coalesce different object shapes in a bulk array into a single union of fields', () => { + const dataArray = [ + { + id: '1', + name: 'Item 1', + metadata: { + tags: ['new', 'sale'], + dimensions: { width: 10, height: 20 } + } + }, + { + id: '2', + price: 19.99, + metadata: { + dimensions: { depth: 5 }, + manufacturer: { name: 'M1', location: { country: 'US' } } + } + }, + { + id: '3', + name: 'Item 3', + metadata: { + tags: ['promo'], + manufacturer: { location: { city: 'SF' } } + } + } + ]; + const fields = apiClient['getFieldsString'](dataArray); + // eslint-disable-next-line max-len + expect(fields).to.equal('id metadata { dimensions { depth height width } manufacturer { location { city country } name } tags } name price'); + }); + }); + // --- 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)); - }); - - 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\\\\" } - }) - }`; - await apiClient.insert(tableName, complexData); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); - }); - - 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 }] - }) - }`; - await apiClient.insert(tableName, dataWithUndefined); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: simpleData } }); }); it('should throw FirebaseDataConnectError for invalid tableName', async () => { @@ -784,7 +846,7 @@ describe('DataConnectApiClient CRUD helpers', () => { await expect(apiClient.insert(tableName, [])) .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be an object, not an array, for single insert./); }); - + it('should amend the message for query errors', async () => { try { await apiClientQueryError.insert(tableName, { data: 1 }); @@ -800,61 +862,26 @@ describe('DataConnectApiClient CRUD helpers', () => { // --- 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", maxCount: ${ALLOW_DIRECTIVE_MAX_COUNT})) { + ${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", maxCount: ${ALLOW_DIRECTIVE_MAX_COUNT})) { + ${formatedTableName}_insertMany(data: $data) + }`; await apiClient.insertMany(tableName, simpleDataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); - }); - - it('should call executeGraphql with the correct mutation for complex data array', async () => { - const complexDataArray = [ - { id: 'a', active: true, info: { nested: 'n1 "quote"' } }, - { 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/\\\\" } }]) }`; - await apiClient.insertMany(tableName, complexDataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); - }); - - 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 }] - }]) - }`; - await apiClient.insertMany(tableName, dataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: simpleDataArray } }); }); it('should throw FirebaseDataConnectError for invalid tableName', async () => { @@ -877,6 +904,16 @@ describe('DataConnectApiClient CRUD helpers', () => { .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-empty array for insertMany./); }); + // eslint-disable-next-line max-len + it(`should throw FirebaseDataConnectError if the data array length exceeds ${ALLOW_DIRECTIVE_MAX_COUNT}`, async () => { + const oversizedArray = new Array(ALLOW_DIRECTIVE_MAX_COUNT + 1).fill({ name: 'a' }); + await expect(apiClient.insertMany(tableName, oversizedArray)) + .to.be.rejectedWith( + FirebaseDataConnectError, + new RegExp(`^\`data\` array exceeds the maximum limit of ${ALLOW_DIRECTIVE_MAX_COUNT} items.$`) + ); + }); + it('should amend the message for query errors', async () => { try { await apiClientQueryError.insertMany(tableName, [{ data: 1 }]); @@ -892,43 +929,26 @@ describe('DataConnectApiClient CRUD helpers', () => { // --- 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" }) }`; - await apiClient.upsert(tableName, simpleData); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(expectedMutation); - }); - - 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/\\\\" } }) }`; - await apiClient.upsert(tableName, complexData); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); - }); - - 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 }] - }) - }`; - await apiClient.upsert(tableName, dataWithUndefined); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + mutation($data: TestTable_Data! @allow(fields: "id value")) { + ${formatedTableName}_upsert(data: $data) + }`; + await apiClient.upsert(tableName, simpleData); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: simpleData } }); }); it('should throw FirebaseDataConnectError for invalid tableName', async () => { @@ -961,58 +981,26 @@ describe('DataConnectApiClient CRUD helpers', () => { // --- 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", maxCount: ${ALLOW_DIRECTIVE_MAX_COUNT})) { + ${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", maxCount: ${ALLOW_DIRECTIVE_MAX_COUNT})) { + ${formatedTableName}_upsertMany(data: $data) + }`; await apiClient.upsertMany(tableName, simpleDataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); - }); - - it('should call executeGraphql with the correct mutation for complex data array', async () => { - const complexDataArray = [ - { id: 'x', active: true, info: { nested: 'n1/\\"x' } }, - { id: 'y', scores: [null, 2] } - ]; - const expectedMutation = ` - mutation { ${formatedTableName}_upsertMany(data: - [{ id: "x", active: true, info: { nested: "n1/\\\\\\"x" } }, { id: "y", scores: [null, 2] }]) }`; - await apiClient.upsertMany(tableName, complexDataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); - }); - - 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 }] - }]) - }`; - await apiClient.upsertMany(tableName, dataArray); - expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); + expectNormalizedExecuteGraphqlCall(expectedMutation, { variables: { data: simpleDataArray } }); }); it('should throw FirebaseDataConnectError for invalid tableName', async () => { @@ -1035,6 +1023,16 @@ describe('DataConnectApiClient CRUD helpers', () => { .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-empty array for upsertMany./); }); + // eslint-disable-next-line max-len + it(`should throw FirebaseDataConnectError if the data array length exceeds ${ALLOW_DIRECTIVE_MAX_COUNT}`, async () => { + const oversizedArray = new Array(ALLOW_DIRECTIVE_MAX_COUNT + 1).fill({ name: 'a' }); + await expect(apiClient.upsertMany(tableName, oversizedArray)) + .to.be.rejectedWith( + FirebaseDataConnectError, + new RegExp(`^\`data\` array exceeds the maximum limit of ${ALLOW_DIRECTIVE_MAX_COUNT} items.$`) + ); + }); + it('should amend the message for query errors', async () => { try { await apiClientQueryError.upsertMany(tableName, [{ data: 1 }]); @@ -1048,48 +1046,48 @@ describe('DataConnectApiClient CRUD helpers', () => { }); 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 +1096,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 😊'); }); }); }); +