Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

16 changes: 13 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,19 @@ And then:
'npx mocha \"test/integration/{auth,database,firestore}.spec.ts\" --slow 5000 --timeout 20000 --require ts-node/register'
```

Currently, only the Auth, Database, and Firestore test suites work. Some test
cases will be automatically skipped due to lack of emulator support. The section
below covers how to run the full test suite against an actual Firebase project.
Currently, only the Auth, Database, and Firestore test suites work. Some test cases
will be automatically skipped due to lack of emulator support.

You can also run the Data Connect test suite against the emulators using the same command,
but with a config file specific to Data Connect emulator testing:

```bash
firebase emulators:exec \
--project fake-project-id --only dataconnect --config test/integration/dataconnect/firebase.json \
'npx mocha \"test/integration/data-connect.spec.ts\" --slow 5000 --timeout 20000 --require ts-node/register'
```

The section below covers how to run the full test suite against an actual Firebase project.

#### Integration Tests with an actual Firebase project

Expand Down
135 changes: 71 additions & 64 deletions src/data-connect/data-connect-api-client-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,59 +438,50 @@ export class DataConnectApiClient {
}

/**
* Converts JSON data into a GraphQL literal string.
* Handles nested objects, arrays, strings, numbers, and booleans.
* Ensures strings are properly escaped.
* Generates both capitalized and camel-cased variations of a table name.
* Capitalization matches the schema types, and camel-case matches mutations.
*/
private objectToString(data: unknown): string {
if (typeof data === 'string') {
return JSON.stringify(data);
}
if (typeof data === 'number' || typeof data === 'boolean' || data === null) {
return String(data);
}
if (validator.isArray(data)) {
const elements = data.map(item => this.objectToString(item)).join(', ');
return `[${elements}]`;
}
if (typeof data === 'object' && data !== null) {
// Filter out properties where the value is undefined BEFORE mapping
const kvPairs = Object.entries(data)
.filter(([, val]) => val !== undefined)
.map(([key, val]) => {
// GraphQL object keys are typically unquoted.
return `${key}: ${this.objectToString(val)}`;
});

if (kvPairs.length === 0) {
return '{}'; // Represent an object with no defined properties as {}
}
return `{ ${kvPairs.join(', ')} }`;
}

// If value is undefined (and not an object property, which is handled above,
// e.g., if objectToString(undefined) is called directly or for an array element)
// it should be represented as 'null'.
if (typeof data === 'undefined') {
return 'null';
private getTableNames(tableName: string): { capitalized: string; camelCase: string } {
if (!tableName || tableName.length === 0) {
return { capitalized: tableName, camelCase: tableName };
}
const capitalized = tableName.charAt(0).toUpperCase() + tableName.slice(1);
const camelCase = tableName.charAt(0).toLowerCase() + tableName.slice(1);
return { capitalized, camelCase };
}

// Fallback for any other types (e.g., Symbol, BigInt - though less common in GQL contexts)
// Consider how these should be handled or if an error should be thrown.
// For now, simple string conversion.
return String(data);
/**
* Extracts all defined property keys from an object as a space-separated string.
* Used to build the `@allow(fields: ...)` mutation directive for single operations.
*/
private getObjectKeys(data: Record<string, unknown> | object): string {
return Object.keys(data)
.filter(key => (data as Record<string, unknown>)[key] !== undefined)
.join(' ');
}
Comment thread
stephenarosaj marked this conversation as resolved.

private formatTableName(tableName: string): string {
// Format tableName: first character to lowercase
if (tableName && tableName.length > 0) {
return tableName.charAt(0).toLowerCase() + tableName.slice(1);
/**
* Extracts the union of all defined property keys across an array of objects
* as a space-separated string. Used to build the `@allow(fields: ...)` mutation
* directive for bulk operations.
*/
private getArrayObjectsKeys(data: Array<unknown>): string {
const allKeys = new Set<string>();
for (const element of data) {
if (validator.isNonNullObject(element)) {
const record = element as Record<string, unknown>;
Object.keys(record).forEach(key => {
if (record[key] !== undefined) {
allKeys.add(key);
}
});
}
}
return tableName;
return Array.from(allKeys).join(' ');
}
Comment thread
stephenarosaj marked this conversation as resolved.

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 `
Expand Down Expand Up @@ -529,11 +520,15 @@ export class DataConnectApiClient {
}

try {
tableName = this.formatTableName(tableName);
const gqlDataString = this.objectToString(data);
const mutation = `mutation { ${tableName}_insert(data: ${gqlDataString}) }`;
// Use internal executeGraphql
return this.executeGraphql<GraphQlResponse, Variables>(mutation).catch(this.handleBulkImportErrors);
const { capitalized, camelCase } = this.getTableNames(tableName);
const keys = this.getObjectKeys(data);
const mutation =
`mutation($data: ${capitalized}_Data! @allow(fields: "${keys}")) {
${camelCase}_insert(data: $data)
}`;

return this.executeGraphql<GraphQlResponse, { data: Variables }>(mutation, { variables: { data } })
.catch(this.handleBulkImportErrors);
} catch (e: any) {
throw new FirebaseDataConnectError({
code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL,
Expand Down Expand Up @@ -564,11 +559,15 @@ export class DataConnectApiClient {
}

try {
tableName = this.formatTableName(tableName);
const gqlDataString = this.objectToString(data);
const mutation = `mutation { ${tableName}_insertMany(data: ${gqlDataString}) }`;
// Use internal executeGraphql
return this.executeGraphql<GraphQlResponse, Variables>(mutation).catch(this.handleBulkImportErrors);
const { capitalized, camelCase } = this.getTableNames(tableName);
const keys = this.getArrayObjectsKeys(data);
const mutation =
`mutation($data: [${capitalized}_Data!]! @allow(fields: "${keys}")) {
${camelCase}_insertMany(data: $data)
}`;

return this.executeGraphql<GraphQlResponse, { data: Variables }>(mutation, { variables: { data } })
.catch(this.handleBulkImportErrors);
} catch (e: any) {
throw new FirebaseDataConnectError({
code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL,
Expand Down Expand Up @@ -606,11 +605,15 @@ export class DataConnectApiClient {
}

try {
tableName = this.formatTableName(tableName);
const gqlDataString = this.objectToString(data);
const mutation = `mutation { ${tableName}_upsert(data: ${gqlDataString}) }`;
// Use internal executeGraphql
return this.executeGraphql<GraphQlResponse, Variables>(mutation).catch(this.handleBulkImportErrors);
const { capitalized, camelCase } = this.getTableNames(tableName);
const keys = this.getObjectKeys(data);
const mutation =
`mutation($data: ${capitalized}_Data! @allow(fields: "${keys}")) {
${camelCase}_upsert(data: $data)
}`;

return this.executeGraphql<GraphQlResponse, { data: Variables }>(mutation, { variables: { data } })
.catch(this.handleBulkImportErrors);
} catch (e: any) {
throw new FirebaseDataConnectError({
code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL,
Expand Down Expand Up @@ -641,11 +644,15 @@ export class DataConnectApiClient {
}

try {
tableName = this.formatTableName(tableName);
const gqlDataString = this.objectToString(data);
const mutation = `mutation { ${tableName}_upsertMany(data: ${gqlDataString}) }`;
// Use internal executeGraphql
return this.executeGraphql<GraphQlResponse, Variables>(mutation).catch(this.handleBulkImportErrors);
const { capitalized, camelCase } = this.getTableNames(tableName);
const keys = this.getArrayObjectsKeys(data);
const mutation =
`mutation($data: [${capitalized}_Data!]! @allow(fields: "${keys}")) {
${camelCase}_upsertMany(data: $data)
}`;

return this.executeGraphql<GraphQlResponse, { data: Variables }>(mutation, { variables: { data } })
.catch(this.handleBulkImportErrors);
} catch (e: any) {
throw new FirebaseDataConnectError({
code: DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL,
Expand Down
Loading
Loading