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..3157cd423c 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,22 @@ 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); + private getTableNames(tableName: string): { capitalized: string; camelCase: string } { + if (!tableName || tableName.length === 0) { + return { capitalized: tableName, camelCase: tableName }; } - 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'; - } - - // 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); + const capitalized = tableName.charAt(0).toUpperCase() + tableName.slice(1); + const camelCase = tableName.charAt(0).toLowerCase() + tableName.slice(1); + return { capitalized, camelCase }; } - private formatTableName(tableName: string): string { - // Format tableName: first character to lowercase - if (tableName && tableName.length > 0) { - return tableName.charAt(0).toLowerCase() + tableName.slice(1); - } - return tableName; - } + 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 +477,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 +487,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 +496,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 +524,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 +536,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 = 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 +568,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 = 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, }); } @@ -689,3 +631,55 @@ interface ServerError { message?: string; status?: string; } + +/** + * 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. + * + * @internal + */ +export function getFieldsString(data: unknown): string { + const root: FieldNode = { children: new Map() }; + mergeFieldsIntoTree(data, root); + return serializeFieldNode(root); +} + +function mergeFieldsIntoTree(data: unknown, node: FieldNode): void { + if (validator.isArray(data)) { + data.forEach((item) => mergeFieldsIntoTree(item, node)); + return; + } + if (!validator.isNonNullObject(data) || data instanceof Date) { + return; + } + 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); + } + if (key.includes('_on_')) { + mergeFieldsIntoTree(val, childNode); + } + } +} + +function 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 = serializeFieldNode(childNode); + parts.push(`${key} { ${nestedString} }`); + } else { + parts.push(key); + } + } + return parts.join(' '); +} 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..d8c1d242f8 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,11 @@ 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, + getFieldsString +} from '../../../src/data-connect/data-connect-api-client-internal'; import { FirebaseDataConnectError, DATA_CONNECT_ERROR_CODE_MAPPING, @@ -64,8 +68,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: { @@ -291,7 +295,7 @@ describe('DataConnectApiClient', () => { describe('should reject with an appropriate error response on failure', () => { it('should reject when no operationName is provided', () => { - apiClient.executeQuery( '', undefined, unauthenticatedOptions) + apiClient.executeQuery('', undefined, unauthenticatedOptions) .should.eventually.be.rejectedWith('`name` must be a non-empty string.'); apiClient.executeQuery(undefined as unknown as string, undefined, unauthenticatedOptions) .should.eventually.be.rejectedWith('`name` must be a non-empty string.'); @@ -299,8 +303,8 @@ describe('DataConnectApiClient', () => { it('should reject when project id is not available', () => { clientWithoutProjectId.executeQuery( - 'unauthenticated query', - undefined, + 'unauthenticated query', + undefined, unauthenticatedOptions ).should.eventually.be.rejectedWith(noProjectId); }); @@ -392,8 +396,8 @@ describe('DataConnectApiClient', () => { .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); const resp = await apiClient.executeQuery( - 'unauthenticated query', - undefined, + 'unauthenticated query', + undefined, unauthenticatedOptions ); expect(resp.data.users).to.be.not.empty; @@ -404,8 +408,8 @@ describe('DataConnectApiClient', () => { method: 'POST', url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, headers: EXPECTED_HEADERS, - data: { - operationName: 'unauthenticated query', + data: { + operationName: 'unauthenticated query', extensions: unauthenticatedOptions } }); @@ -416,8 +420,8 @@ describe('DataConnectApiClient', () => { .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); const resp = await apiClient.executeQuery( - 'authenticated query', - undefined, + 'authenticated query', + undefined, authenticatedOptions ); expect(resp.data.users).to.be.not.empty; @@ -428,8 +432,8 @@ describe('DataConnectApiClient', () => { method: 'POST', url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, headers: EXPECTED_HEADERS, - data: { - operationName: 'authenticated query', + data: { + operationName: 'authenticated query', extensions: { impersonate: authenticatedOptions.impersonate } } }); @@ -442,25 +446,25 @@ describe('DataConnectApiClient', () => { .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); await apiClient.executeQuery( - 'unauthenticated query', - undefined, + 'unauthenticated query', + undefined, unauthenticatedOptions ); expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, headers: EMULATOR_EXPECTED_HEADERS, - data: { - operationName: 'unauthenticated query', + data: { + operationName: 'unauthenticated query', extensions: unauthenticatedOptions } }); }); }); - const unauthenticatedOptions: OperationOptions = + const unauthenticatedOptions: OperationOptions = { impersonate: { unauthenticated: true } }; - const authenticatedOptions: OperationOptions = + const authenticatedOptions: OperationOptions = { impersonate: { authClaims: { sub: 'authenticated-UUID' } } }; describe('executeMutation', () => { @@ -573,8 +577,8 @@ describe('DataConnectApiClient', () => { method: 'POST', url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, headers: EXPECTED_HEADERS, - data: { - operationName: 'unauthenticated mutation', + data: { + operationName: 'unauthenticated mutation', extensions: unauthenticatedOptions } }); @@ -595,8 +599,8 @@ describe('DataConnectApiClient', () => { method: 'POST', url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, headers: EXPECTED_HEADERS, - data: { - operationName: 'authenticated mutation', + data: { + operationName: 'authenticated mutation', extensions: authenticatedOptions } }); @@ -613,8 +617,8 @@ describe('DataConnectApiClient', () => { method: 'POST', url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, headers: EMULATOR_EXPECTED_HEADERS, - data: { - operationName: 'unauthenticated mutation', + data: { + operationName: 'unauthenticated mutation', extensions: unauthenticatedOptions } }); @@ -699,9 +703,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 +742,123 @@ describe('DataConnectApiClient CRUD helpers', () => { return mockApp.delete(); }); + // --- GET FIELDS STRING TESTS --- + describe('getFieldsString()', () => { + describe('single object', () => { + it('should extract keys from a simple object sorted alphabetically', () => { + const data = { name: 'test', value: 123 }; + const fields = getFieldsString(data); + expect(fields).to.equal('name value'); + }); + + it('should recursively extract nested object fields for objects with _on_ field names', () => { + const data = { + id: 'abc', + active: true, + scores: [10, 20], + info_on_test: { nested: 'yes/no "quote" \\slash\\' } + }; + const fields = getFieldsString(data); + expect(fields).to.equal('active id info_on_test { nested } scores'); + }); + + it('should recursively extract deep nested object/array fields for objects with _on_ field names', () => { + const deepData = { + id: '1', + tags_on_item: { name: 'Tag1', count: 1, colors_on_tag: { primary: 'red', secondary: 'red' } }, + }; + const fields = getFieldsString(deepData); + expect(fields).to.equal('id tags_on_item { colors_on_tag { primary secondary } count name }'); + }); + + it('should skip undefined fields and handle nulls/empty objects', () => { + const fields = getFieldsString(dataWithUndefined); + expect(fields).to.equal('director extras genre ratings title'); + }); + }); + + describe('array of objects', () => { + it('should extract and coalesce keys from simple objects sorted alphabetically', () => { + const dataArray = [ + { name: 'test' }, + { value: 123 }, + { name: 'another', other: true } + ]; + const fields = getFieldsString(dataArray); + expect(fields).to.equal('name other value'); + }); + + it('should extract and coalesce different object shapes in a bulk array into a single union of fields', () => { + const dataArray = [ + { + id: '1', + name: 'Item 1', + }, + { + id: '2', + price: 19.99, + }, + { + id: '3', + name: 'Item 3', + } + ]; + const fields = getFieldsString(dataArray); + expect(fields).to.equal('id name price'); + }); + + it('should recursively extract and coalesce nested object fields for objects with _on_ field names', () => { + const dataArray = [ + { id: 'abc', active: true, info_on_test: { nested: 'yes' } }, + { scores: [10, 20], info_on_test: { other: 123 } } + ]; + const fields = getFieldsString(dataArray); + expect(fields).to.equal('active id info_on_test { nested other } scores'); + }); + + it('should recursively coalesce deep nested fields for objects with _on_ names', () => { + const dataArray = [ + { id: '1', tags_on_item: { name: 'Tag1', colors_on_tag: { primary: 'red' } } }, + { tags_on_item: { count: 1, colors_on_tag: { secondary: 'blue' } } } + ]; + const fields = getFieldsString(dataArray); + expect(fields).to.equal('id tags_on_item { colors_on_tag { primary secondary } count name }'); + }); + + it('should skip undefined fields and handle nulls/empty objects across multiple objects in array', () => { + const dataArray = [ + dataWithUndefined, + { notes: 'actual note', releaseYear: 2024, genre: undefined } + ]; + const fields = getFieldsString(dataArray); + expect(fields).to.equal('director extras genre notes ratings releaseYear title'); + }); + }); + }); + // --- 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 () => { @@ -780,11 +871,11 @@ describe('DataConnectApiClient CRUD helpers', () => { .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-null object./); }); - it('should throw FirebaseDataConnectError for array data', async() => { + it('should throw FirebaseDataConnectError for array data', async () => { 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 +891,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 +933,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 +958,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 +1010,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 +1052,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 +1075,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 +1125,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 😊'); }); }); }); +