diff --git a/etc/firebase-admin.data-connect.api.md b/etc/firebase-admin.data-connect.api.md index 3650368734..2284d14b65 100644 --- a/etc/firebase-admin.data-connect.api.md +++ b/etc/firebase-admin.data-connect.api.md @@ -29,6 +29,14 @@ export class DataConnect { executeGraphql(query: string, options?: GraphqlOptions): Promise>; // @beta executeGraphqlRead(query: string, options?: GraphqlOptions): Promise>; + // @beta + insert(tableName: string, variables: Variables): Promise>; + // @beta + insertMany>(tableName: string, variables: Variables): Promise>; + // @beta + upsert(tableName: string, variables: Variables): Promise>; + // @beta + upsertMany>(tableName: string, variables: Variables): Promise>; } // @public diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index b6082b91f5..e12005b8bb 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -198,6 +198,203 @@ export class DataConnectApiClient { const message = error.message || `Unknown server error: ${response.text}`; return new FirebaseDataConnectError(code, message); } + + /** + * Converts JSON data into a GraphQL literal string. + * Handles nested objects, arrays, strings, numbers, and booleans. + * Ensures strings are properly escaped. + */ + private objectToString(data: unknown): string { + if (typeof data === 'string') { + const escapedString = data + .replace(/\\/g, '\\\\') // Replace \ with \\ + .replace(/"/g, '\\"'); // Replace " with \" + return `"${escapedString}"`; + } + 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); + } + + 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}`){ + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, + `${err.message}. Make sure that your table name passed in matches the type name in your GraphQL schema file.`); + } + throw err; + } + + /** + * Insert a single row into the specified table. + */ + public async insert( + tableName: string, + data: Variables, + ): Promise> { + if (!validator.isNonEmptyString(tableName)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`tableName` must be a non-empty string.'); + } + if (validator.isArray(data)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`data` must be an object, not an array, for single insert. For arrays, please use `insertMany` function.'); + } + if (!validator.isNonNullObject(data)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`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( + DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, + `Failed to construct insert mutation: ${e.message}`); + } + } + + /** + * Insert multiple rows into the specified table. + */ + public async insertMany>( + tableName: string, + data: Variables, + ): Promise> { + if (!validator.isNonEmptyString(tableName)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`tableName` must be a non-empty string.'); + } + if (!validator.isNonEmptyArray(data)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`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(DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, + `Failed to construct insertMany mutation: ${e.message}`); + } + } + + /** + * Insert a single row into the specified table, or update it if it already exists. + */ + public async upsert( + tableName: string, + data: Variables, + ): Promise> { + if (!validator.isNonEmptyString(tableName)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`tableName` must be a non-empty string.'); + } + if (validator.isArray(data)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`data` must be an object, not an array, for single upsert. For arrays, please use `upsertMany` function.'); + } + if (!validator.isNonNullObject(data)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`data` must be a non-null object.'); + } + + 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); + } catch (e: any) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, + `Failed to construct upsert mutation: ${e.message}`); + } + } + + /** + * Insert multiple rows into the specified table, or update them if they already exist. + */ + public async upsertMany>( + tableName: string, + data: Variables, + ): Promise> { + if (!validator.isNonEmptyString(tableName)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`tableName` must be a non-empty string.'); + } + if (!validator.isNonEmptyArray(data)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`data` must be a non-empty array for upsertMany.'); + } + + 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); + } catch (e: any) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, + `Failed to construct upsertMany mutation: ${e.message}`); + } + } } /** diff --git a/src/data-connect/data-connect.ts b/src/data-connect/data-connect.ts index dbded00042..671bfe4b73 100644 --- a/src/data-connect/data-connect.ts +++ b/src/data-connect/data-connect.ts @@ -46,10 +46,10 @@ export class DataConnectService { } /** - * Returns the app associated with this `DataConnectService` instance. - * - * @returns The app associated with this `DataConnectService` instance. - */ + * Returns the app associated with this `DataConnectService` instance. + * + * @returns The app associated with this `DataConnectService` instance. + */ get app(): App { return this.appInternal; } @@ -63,24 +63,24 @@ export class DataConnect { private readonly client: DataConnectApiClient; /** - * @param connectorConfig - The connector configuration. - * @param app - The app for this `DataConnect` service. - * @constructor - * @internal - */ + * @param connectorConfig - The connector configuration. + * @param app - The app for this `DataConnect` service. + * @constructor + * @internal + */ constructor(readonly connectorConfig: ConnectorConfig, readonly app: App) { this.client = new DataConnectApiClient(connectorConfig, app); } /** - * Execute an arbitrary GraphQL query or mutation - * - * @param query - The GraphQL query or mutation. - * @param options - Optional {@link GraphqlOptions} when executing a GraphQL query or mutation. - * - * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. - * @beta - */ + * Execute an arbitrary GraphQL query or mutation + * + * @param query - The GraphQL query or mutation. + * @param options - Optional {@link GraphqlOptions} when executing a GraphQL query or mutation. + * + * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. + * @beta + */ public executeGraphql( query: string, options?: GraphqlOptions, @@ -103,4 +103,64 @@ export class DataConnect { ): Promise> { return this.client.executeGraphqlRead(query, options); } + + /** + * Insert a single row into the specified table. + * + * @param tableName - The name of the table to insert data into. + * @param variables - The data object to insert. The keys should correspond to the column names. + * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. + * @beta + */ + public insert( + tableName: string, + variables: Variables, + ): Promise> { + return this.client.insert(tableName, variables); + } + + /** + * Insert multiple rows into the specified table. + * + * @param tableName - The name of the table to insert data into. + * @param variables - An array of data objects to insert. Each object's keys should correspond to the column names. + * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. + * @beta + */ + public insertMany>( + tableName: string, + variables: Variables, + ): Promise> { + return this.client.insertMany(tableName, variables); + } + + /** + * Insert a single row into the specified table, or update it if it already exists. + * + * @param tableName - The name of the table to upsert data into. + * @param variables - The data object to upsert. The keys should correspond to the column names. + * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. + * @beta + */ + public upsert( + tableName: string, + variables: Variables, + ): Promise> { + return this.client.upsert(tableName, variables); + } + + /** + * Insert multiple rows into the specified table, or update them if they already exist. + * + * @param tableName - The name of the table to upsert data into. + * @param variables - An array of data objects to upsert. Each object's keys should correspond to the column names. + * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. + * @beta + */ + public upsertMany>( + tableName: string, + variables: Variables, + ): Promise> { + return this.client.upsertMany(tableName, variables); + } } 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 9787a2d7c9..a5798703e5 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,7 @@ import { } from '../../../src/utils/api-request'; import * as utils from '../utils'; import * as mocks from '../../resources/mocks'; -import { DataConnectApiClient, FirebaseDataConnectError } +import { DATA_CONNECT_ERROR_CODE_MAPPING, DataConnectApiClient, FirebaseDataConnectError } from '../../../src/data-connect/data-connect-api-client-internal'; import { FirebaseApp } from '../../../src/app/firebase-app'; import { ConnectorConfig } from '../../../src/data-connect'; @@ -46,6 +46,12 @@ describe('DataConnectApiClient', () => { 'X-Goog-Api-Client': getMetricsHeader(), }; + const EMULATOR_EXPECTED_HEADERS = { + 'Authorization': 'Bearer owner', + 'X-Firebase-Client': `fire-admin-node/${getSdkVersion()}`, + 'X-Goog-Api-Client': getMetricsHeader(), + }; + 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.'; @@ -218,10 +224,382 @@ describe('DataConnectApiClient', () => { expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', url: `http://localhost:9399/v1alpha/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, - headers: EXPECTED_HEADERS, + headers: EMULATOR_EXPECTED_HEADERS, data: { query: 'query' } }); }); }); }); }); + +describe('DataConnectApiClient CRUD helpers', () => { + let mockApp: FirebaseApp; + let apiClient: DataConnectApiClient; + let apiClientQueryError: DataConnectApiClient; + let executeGraphqlStub: sinon.SinonStub; + + const connectorConfig: ConnectorConfig = { + location: 'us-west1', + serviceId: 'my-crud-service', + }; + + const mockOptions = { + credential: new mocks.MockCredential(), + projectId: 'test-project-crud', + }; + + const tableName = 'TestTable'; + const formatedTableName = 'testTable'; + + const dataWithUndefined = { + genre: 'Action', + title: 'Die Hard', + ratings: null, + director: { + name: undefined, + age: undefined + }, + notes: undefined, + releaseYear: undefined, + extras: [1, undefined, 'hello', undefined, { a: 1, b: undefined }] + }; + + const tableNames = ['movie', 'Movie', 'MOVIE', 'mOvIE', 'toybox', 'toyBox', 'toyBOX', 'ToyBox', 'TOYBOX']; + const formatedTableNames = ['movie', 'movie', 'mOVIE', 'mOvIE', 'toybox', 'toyBox', 'toyBOX', 'toyBox', 'tOYBOX']; + + const serverErrorString = 'Server error response'; + const additionalErrorMessageForBulkImport = + 'Make sure that your table name passed in matches the type name in your GraphQL schema file.'; + + const expectedQueryError = new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, + serverErrorString + ); + + // 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 + }; + + beforeEach(() => { + mockApp = mocks.appWithOptions(mockOptions); + apiClient = new DataConnectApiClient(connectorConfig, mockApp); + apiClientQueryError = new DataConnectApiClient(connectorConfig, mockApp); + // Stub the instance's executeGraphql method + executeGraphqlStub = sinon.stub(apiClient, 'executeGraphql').resolves({ data: {} }); + sinon.stub(apiClientQueryError, 'executeGraphql').rejects(expectedQueryError); + }); + + afterEach(() => { + sinon.restore(); + return mockApp.delete(); + }); + + // --- INSERT TESTS --- + describe('insert()', () => { + tableNames.forEach((tableName, index) => { + const expectedMutation = `mutation { ${formatedTableNames[index]}_insert(data: { name: "a" }) }`; + 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)); + }); + }); + + 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 + }) + }`; + 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)); + }); + + it('should throw FirebaseDataConnectError for invalid tableName', async () => { + await expect(apiClient.insert('', { data: 1 })) + .to.be.rejectedWith(FirebaseDataConnectError, /`tableName` must be a non-empty string./); + }); + + it('should throw FirebaseDataConnectError for null data', async () => { + await expect(apiClient.insert(tableName, null as any)) + .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-null object./); + }); + + 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 () => { + await expect(apiClientQueryError.insert(tableName, { data: 1 })) + .to.be.rejectedWith(FirebaseDataConnectError, `${serverErrorString}. ${additionalErrorMessageForBulkImport}`); + }); + }); + + // --- INSERT MANY TESTS --- + describe('insertMany()', () => { + tableNames.forEach((tableName, index) => { + const expectedMutation = `mutation { ${formatedTableNames[index]}_insertMany(data: [{ name: "a" }]) }`; + 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)); + }); + }); + + 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 }]) }`; + 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)); + }); + + it('should throw FirebaseDataConnectError for invalid tableName', async () => { + await expect(apiClient.insertMany('', [{ data: 1 }])) + .to.be.rejectedWith(FirebaseDataConnectError, /`tableName` must be a non-empty string./); + }); + + it('should throw FirebaseDataConnectError for null data', () => { + expect(apiClient.insertMany(tableName, null as any)) + .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-empty array for insertMany./); + }); + + it('should throw FirebaseDataConnectError for empty array data', () => { + expect(apiClient.insertMany(tableName, [])) + .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-empty array for insertMany./); + }); + + it('should throw FirebaseDataConnectError for non-array data', () => { + expect(apiClient.insertMany(tableName, { data: 1 } as any)) + .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-empty array for insertMany./); + }); + + it('should amend the message for query errors', async () => { + await expect(apiClientQueryError.insertMany(tableName, [{ data: 1 }])) + .to.be.rejectedWith(FirebaseDataConnectError, `${serverErrorString}. ${additionalErrorMessageForBulkImport}`); + }); + }); + + // --- UPSERT TESTS --- + describe('upsert()', () => { + tableNames.forEach((tableName, index) => { + const expectedMutation = `mutation { ${formatedTableNames[index]}_upsert(data: { name: "a" }) }`; + 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)); + }); + }); + + 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)); + }); + + it('should throw FirebaseDataConnectError for invalid tableName', async () => { + await expect(apiClient.upsert('', { data: 1 })) + .to.be.rejectedWith(FirebaseDataConnectError, /`tableName` must be a non-empty string./); + }); + + it('should throw FirebaseDataConnectError for null data', async () => { + await expect(apiClient.upsert(tableName, null as any)) + .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-null object./); + }); + + it('should throw FirebaseDataConnectError for array data', async () => { + await expect(apiClient.upsert(tableName, [{ data: 1 }])) + .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be an object, not an array, for single upsert./); + }); + + it('should amend the message for query errors', async () => { + await expect(apiClientQueryError.upsert(tableName, { data: 1 })) + .to.be.rejectedWith(FirebaseDataConnectError, `${serverErrorString}. ${additionalErrorMessageForBulkImport}`); + }); + }); + + // --- UPSERT MANY TESTS --- + describe('upsertMany()', () => { + tableNames.forEach((tableName, index) => { + const expectedMutation = `mutation { ${formatedTableNames[index]}_upsertMany(data: [{ name: "a" }]) }`; + 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)); + }); + }); + + 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 }]) }`; + 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)); + }); + + it('should throw FirebaseDataConnectError for invalid tableName', async () => { + expect(apiClient.upsertMany('', [{ data: 1 }])) + .to.be.rejectedWith(FirebaseDataConnectError, /`tableName` must be a non-empty string./); + }); + + it('should throw FirebaseDataConnectError for null data', async () => { + expect(apiClient.upsertMany(tableName, null as any)) + .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-empty array for upsertMany./); + }); + + it('should throw FirebaseDataConnectError for empty array data', async () => { + expect(apiClient.upsertMany(tableName, [])) + .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-empty array for upsertMany./); + }); + + it('should throw FirebaseDataConnectError for non-array data', async () => { + await expect(apiClient.upsertMany(tableName, { data: 1 } as any)) + .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-empty array for upsertMany./); + }); + + it('should amend the message for query errors', async () => { + await expect(apiClientQueryError.upsertMany(tableName, [{ data: 1 }])) + .to.be.rejectedWith(FirebaseDataConnectError, `${serverErrorString}. ${additionalErrorMessageForBulkImport}`); + }); + }); +}); diff --git a/test/unit/data-connect/index.spec.ts b/test/unit/data-connect/index.spec.ts index 228cafbf84..b71670544b 100644 --- a/test/unit/data-connect/index.spec.ts +++ b/test/unit/data-connect/index.spec.ts @@ -18,12 +18,14 @@ 'use strict'; import * as chai from 'chai'; +import * as sinon from 'sinon'; import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../../resources/mocks'; import { App } from '../../../src/app/index'; import { getDataConnect, DataConnect } from '../../../src/data-connect/index'; +import { DataConnectApiClient } from '../../../src/data-connect/data-connect-api-client-internal'; chai.should(); chai.use(sinonChai); @@ -84,3 +86,72 @@ describe('DataConnect', () => { }); }); }); + +describe('DataConnect CRUD helpers delegation', () => { + let mockApp: App; + let dataConnect: DataConnect; + // Stubs for the client methods + let clientInsertStub: sinon.SinonStub; + let clientInsertManyStub: sinon.SinonStub; + let clientUpsertStub: sinon.SinonStub; + let clientUpsertManyStub: sinon.SinonStub; + + const connectorConfig = { + location: 'us-west1', + serviceId: 'my-crud-service', + }; + + const testTableName = 'TestTable'; + + beforeEach(() => { + mockApp = mocks.app(); + + dataConnect = getDataConnect(connectorConfig, mockApp); + + // Stub the DataConnectApiClient prototype methods + clientInsertStub = sinon.stub(DataConnectApiClient.prototype, 'insert').resolves({ data: {} }); + clientInsertManyStub = sinon.stub(DataConnectApiClient.prototype, 'insertMany').resolves({ data: {} }); + clientUpsertStub = sinon.stub(DataConnectApiClient.prototype, 'upsert').resolves({ data: {} }); + clientUpsertManyStub = sinon.stub(DataConnectApiClient.prototype, 'upsertMany').resolves({ data: {} }); + }); + + afterEach(() => { + sinon.restore(); + }); + + // --- INSERT TESTS --- + describe('insert()', () => { + it('should delegate insert call to the client', async () => { + const simpleData = { name: 'test', value: 123 }; + await dataConnect.insert(testTableName, simpleData); + expect(clientInsertStub).to.have.been.calledOnceWithExactly(testTableName, simpleData); + }); + }); + + // --- INSERT MANY TESTS --- + describe('insertMany()', () => { + it('should delegate insertMany call to the client', async () => { + const simpleDataArray = [{ name: 'test1' }, { name: 'test2' }]; + await dataConnect.insertMany(testTableName, simpleDataArray); + expect(clientInsertManyStub).to.have.been.calledOnceWithExactly(testTableName, simpleDataArray); + }); + }); + + // --- UPSERT TESTS --- + describe('upsert()', () => { + it('should delegate upsert call to the client', async () => { + const simpleData = { id: 'key1', value: 'updated' }; + await dataConnect.upsert(testTableName, simpleData); + expect(clientUpsertStub).to.have.been.calledOnceWithExactly(testTableName, simpleData); + }); + }); + + // --- UPSERT MANY TESTS --- + describe('upsertMany()', () => { + it('should delegate upsertMany call to the client', async () => { + const simpleDataArray = [{ id: 'k1' }, { id: 'k2' }]; + await dataConnect.upsertMany(testTableName, simpleDataArray); + expect(clientUpsertManyStub).to.have.been.calledOnceWithExactly(testTableName, simpleDataArray); + }); + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 53847f6d62..f6bfb1b39a 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -117,3 +117,7 @@ import './functions/functions-api-client-internal.spec'; // Extensions import './extensions/extensions.spec'; import './extensions/extensions-api-client-internal.spec'; + +// Data Connect +import './data-connect/index.spec'; +import './data-connect/data-connect-api-client-internal.spec';