Skip to content
109 changes: 109 additions & 0 deletions skills/data-connect/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
name: data-connect-basics
description: Comprehensive guide for developing with Firebase Data Connect. Use this skill when users need to (1) Provision a new Data Connect service, (2) Write Data Connect schemas (.gql files with @table), (3) Write queries and mutations, or (4) Generate and use typed SDKs.
---

# Firebase Data Connect

Firebase Data Connect maps GraphQL to Cloud SQL (PostgreSQL), providing typed interactions and local development tools.

## Project Structure & Configuration

```
dataconnect/
├── dataconnect.yaml # Main service configuration. Required.
├── schema/
│ └── schema.gql # GraphQL schema with @table definitions. Required.
└── connector/
├── connector.yaml # Connector configuration. Required.
├── queries.gql # Any .GQL files in this directory will be included in the connector.
└── mutations.gql
```

### Service Configuration (`dataconnect.yaml`)

Defines the service, location, and database connection. Replace the values with your own.

```yaml
specVersion: "v1"
serviceId: "my-service"
location: "us-east4"
schema:
source: "./schema"
datasource:
postgresql:
database: "fdcdb"
cloudSql:
instanceId: "my-project-id:us-east4:my-instance"
connectorDirs: ["./connector"]
```

### Connector Configuration (`connector.yaml`)

Defines the connector ID and SDK generation settings.

```yaml
connectorId: "my-connector"
generate:
javascriptSdk:
outputDir: "../../js/generated"
package: "@firebasegen/default-connector"
```

## Schema Definition (`schema.gql`)

Data Connect schemas use GraphQL syntax with the `@table` directive to map types to PostgreSQL tables.

### Key Concepts

* **@table**: Helper directive to map a type to a table.
* **@col**: Helper directive to customize column definition (e.g., `dataType`, `name`).
* **@default**: Helper directive to set default values (e.g., `expr: "auth.uid"`, `expr: "request.time"`).
* **Relationships**:
* **One-to-Many**: Define a field of the related type in the "Many" side table.
* **One-to-One**: Use `@unique` on the foreign key field.
* **Many-to-Many**: Create a join table with composite keys.


## Writing Schemas and Operations

Follow this iterative workflow to ensure correctness:

1. **Write Schema**: Define your types in `schema/schema.gql`.
2. **Validate Schema**: Run `firebase dataconnect:compile`.
* Fix any errors reported.
* Repeat until compilation succeeds.
3. **Inspect Generated Types**: Read the contents of `.dataconnect/` to understand the generated type definitions.
4. **Write Operations**: Create queries and mutations in `connector/` (e.g., `queries.gql`).
5. **Validate Operations**: Run `firebase dataconnect:compile`.
* Fix any errors.
* Repeat until compilation succeeds.
6. **Test**: Write unit tests to validate that each operation behaves as expected.

### Example GQL

See [schema_example.gql](references/schema_example.gql).

See [queries_example.gql](references/queries_example.gql) for examples of listing, filtering, and joining data.

See [mutations_example.gql](references/mutations_example.gql) for examples of creating, updating (upsert), and deleting data securely.

### Key Directives

* **@auth(level: ...)**: Controls access level.
* `PUBLIC`: Accessible by anyone (requires `insecureReason`).
* `USER`: Accessible by any authenticated user.
* `USER_EMAIL_VERIFIED`: Accessible by potential verified users.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The description for USER_EMAIL_VERIFIED is a bit ambiguous. "potential verified users" could be misinterpreted. It would be clearer to state that it's for users who have a verified email.

Suggested change
* `USER_EMAIL_VERIFIED`: Accessible by potential verified users.
* `USER_EMAIL_VERIFIED`: Accessible by users with a verified email.

* `NO_ACCESS`: Admin only (internal use).
* **Note**: You can also use `id_expr: "auth.uid"` in filters/data to restrict access to the specific user.

## SDK Generation

Data Connect generates typed SDKs for your client apps (Web, Android, iOS, Dart).

1. **Configure**: Ensure `connector.yaml` has the `generate` block (as shown above).
2. **Generate**: Run `firebase dataconnect:sdk:generate`.
* Use `--watch` to auto-regenerate on changes.
3. **Use in App**:
* Import the generated connector and operations.
* Call operation functions (e.g., `listMovies()`, `createMovie(...)`).
33 changes: 33 additions & 0 deletions skills/data-connect/references/mutations_example.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Example mutations for a simple movie app

# Create a movie based on user input
mutation CreateMovie($title: String!, $genre: String!, $imageUrl: String!)
@auth(level: USER_EMAIL_VERIFIED, insecureReason: "Any email verified users can create a new movie.") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The @auth directive for USER_EMAIL_VERIFIED probably doesn't require an insecureReason. According to the documentation in SKILL.md, only the PUBLIC level requires it. Including it here can be confusing and might imply the operation is less secure than it is.

@auth(level: USER_EMAIL_VERIFIED) {

movie_insert(data: { title: $title, genre: $genre, imageUrl: $imageUrl })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The data object for the movie_insert mutation has incorrect GraphQL syntax. Fields within an input object should not be separated by commas. This will cause a parsing error.

  movie_insert(data: { title: $title genre: $genre imageUrl: $imageUrl })

}

# Upsert (update or insert) a user's username based on their auth.uid
mutation UpsertUser($username: String!) @auth(level: USER) {
# The "auth.uid" server value ensures that users can only register their own user.
user_upsert(data: { id_expr: "auth.uid", username: $username })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The data object for the user_upsert mutation has incorrect GraphQL syntax. Fields within an input object should not be separated by commas. This will cause a parsing error.

  user_upsert(data: { id_expr: "auth.uid" username: $username })

}

# Add a review for a movie
mutation AddReview($movieId: UUID!, $rating: Int!, $reviewText: String!)
@auth(level: USER) {
review_upsert(
data: {
userId_expr: "auth.uid"
movieId: $movieId
rating: $rating
reviewText: $reviewText
# reviewDate defaults to today in the schema. No need to set it manually.
}
)
}

# Logged in user can delete their review for a movie
mutation DeleteReview($movieId: UUID!) @auth(level: USER) {
# The "auth.uid" server value ensures that users can only delete their own reviews.
review_delete(key: { userId_expr: "auth.uid", movieId: $movieId })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The key object for the review_delete mutation has incorrect GraphQL syntax. Fields within an input object should not be separated by commas. This will cause a parsing error.

  review_delete(key: { userId_expr: "auth.uid" movieId: $movieId })

}
79 changes: 79 additions & 0 deletions skills/data-connect/references/queries_example.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Example queries for a simple movie app.

# @auth() directives control who can call each operation.
# Anyone should be able to list all movies, so the auth level is set to PUBLIC
query ListMovies @auth(level: PUBLIC, insecureReason: "Anyone can list all movies.") {
movies {
id
title
imageUrl
genre
}
}

# List all users, only admins should be able to list all users, so we use NO_ACCESS
query ListUsers @auth(level: NO_ACCESS) {
users {
id
username
}
}

# Logged in users can list all their reviews and movie titles associated with the review
# Since the query uses the uid of the current authenticated user, we set auth level to USER
query ListUserReviews @auth(level: USER) {
user(key: { id_expr: "auth.uid" }) {
id
username
# <field>_on_<foreign_key_field> makes it easy to grab info from another table
# Here, we use it to grab all the reviews written by the user.
reviews: reviews_on_user {
rating
reviewDate
reviewText
movie {
id
title
}
}
}
}

# Get movie by id
query GetMovieById($id: UUID!) @auth(level: PUBLIC, insecureReason: "Anyone can get a movie by id.") {
movie(id: $id) {
id
title
imageUrl
genre
metadata: movieMetadata_on_movie {
rating
releaseYear
description
# metadata is valid only if movie exists
}
reviews: reviews_on_movie {
reviewText
reviewDate
rating
user {
id
username
}
}
}
}

# Search for movies, actors, and reviews
query SearchMovie($titleInput: String, $genre: String) @auth(level: PUBLIC, insecureReason: "Anyone can search for movies.") {
movies(
where: {
_and: [{ genre: { eq: $genre } }, { title: { contains: $titleInput } }]
}
) {
id
title
genre
imageUrl
}
}
52 changes: 52 additions & 0 deletions skills/data-connect/references/schema_example.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Example schema for simple movie review app

# User table is keyed by Firebase Auth UID.
type User @table {
# `@default(expr: "auth.uid")` sets it to Firebase Auth UID during insert and upsert.
id: String! @default(expr: "auth.uid")
username: String! @col(dataType: "varchar(50)")
# The `user: User!` field in the Review table generates the following one-to-many query field.
# reviews_on_user: [Review!]!
# The `Review` join table the following many-to-many query field.
# movies_via_Review: [Movie!]!
}

# Movie is keyed by a randomly generated UUID.
type Movie @table {
# If you do not pass a 'key' to `@table`, Data Connect automatically adds the following 'id' column.
# Feel free to uncomment and customize it.
# id: UUID! @default(expr: "uuidV4()")
title: String!
imageUrl: String!
genre: String
}

# MovieMetadata is a metadata attached to a Movie.
# Movie <-> MovieMetadata is a one-to-one relationship
type MovieMetadata @table {
# @unique ensures each Movie can only one MovieMetadata.
movie: Movie! @unique
# The movie field adds the following foreign key field. Feel free to uncomment and customize it.
# movieId: UUID!
rating: Float
releaseYear: Int
description: String
}

# Reviews is a join table between User and Movie.
# It has a composite primary keys `userUid` and `movieId`.
# A user can leave reviews for many movies. A movie can have reviews from many users.
# User <-> Review is a one-to-many relationship
# Movie <-> Review is a one-to-many relationship
# Movie <-> User is a many-to-many relationship
type Review @table(name: "Reviews", key: ["movie", "user"]) {
user: User!
# The user field adds the following foreign key field. Feel free to uncomment and customize it.
# userUid: String!
movie: Movie!
# The movie field adds the following foreign key field. Feel free to uncomment and customize it.
# movieId: UUID!
rating: Int
reviewText: String
reviewDate: Date! @default(expr: "request.time")
}
Loading