Initial commit
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: firebase-data-connect
|
||||
description: Build and deploy Firebase Data Connect backends with PostgreSQL. Use for schema design, GraphQL queries/mutations, authorization, and SDK generation for web, Android, iOS, and Flutter apps.
|
||||
---
|
||||
|
||||
# Firebase Data Connect
|
||||
|
||||
Firebase Data Connect is a relational database service using Cloud SQL for PostgreSQL with GraphQL schema, auto-generated queries/mutations, and type-safe SDKs.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
dataconnect/
|
||||
├── dataconnect.yaml # Service configuration
|
||||
├── schema/
|
||||
│ └── schema.gql # Data model (types with @table)
|
||||
└── connector/
|
||||
├── connector.yaml # Connector config + SDK generation
|
||||
├── queries.gql # Queries
|
||||
└── mutations.gql # Mutations
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
Follow this strict workflow to build your application. You **must** read the linked reference files for each step to understand the syntax and available features.
|
||||
|
||||
### 1. Define Data Model (`schema/schema.gql`)
|
||||
Define your GraphQL types, tables, and relationships.
|
||||
> **Read [reference/schema.md](reference/schema.md)** for:
|
||||
> * `@table`, `@col`, `@default`
|
||||
> * Relationships (`@ref`, one-to-many, many-to-many)
|
||||
> * Data types (UUID, Vector, JSON, etc.)
|
||||
|
||||
### 2. Define Operations (`connector/queries.gql`, `connector/mutations.gql`)
|
||||
Write the queries and mutations your client will use. Data Connect generates the underlying SQL.
|
||||
> **Read [reference/operations.md](reference/operations.md)** for:
|
||||
> * **Queries**: Filtering (`where`), Ordering (`orderBy`), Pagination (`limit`/`offset`).
|
||||
> * **Mutations**: Create (`_insert`), Update (`_update`), Delete (`_delete`).
|
||||
> * **Upserts**: Use `_upsert` to "insert or update" records (CRITICAL for user profiles).
|
||||
> * **Transactions**: use `@transaction` for multi-step atomic operations.
|
||||
|
||||
### 3. Secure Your App (`connector/` files)
|
||||
Add authorization logic closely with your operations.
|
||||
> **Read [reference/security.md](reference/security.md)** for:
|
||||
> * `@auth(level: ...)` for PUBLIC, USER, or NO_ACCESS.
|
||||
> * `@check` and `@redact` for row-level security and validation.
|
||||
|
||||
### 4. Generate & Use SDKs
|
||||
Generate type-safe code for your client platform.
|
||||
> **Read [reference/sdks.md](reference/sdks.md)** for:
|
||||
> * Android (Kotlin), iOS (Swift), Web (TypeScript), Flutter (Dart).
|
||||
> * How to initialize and call your queries/mutations.
|
||||
> * **Nested Data**: See how to access related fields (e.g., `movie.reviews`).
|
||||
|
||||
---
|
||||
|
||||
## Feature Capability Map
|
||||
|
||||
If you need to implement a specific feature, consult the mapped reference file:
|
||||
|
||||
| Feature | Reference File | Key Concepts |
|
||||
| :--- | :--- | :--- |
|
||||
| **Data Modeling** | [reference/schema.md](reference/schema.md) | `@table`, `@unique`, `@index`, Relations |
|
||||
| **Vector Search** | [reference/advanced.md](reference/advanced.md) | `Vector`, `@col(dataType: "vector")` |
|
||||
| **Full-Text Search** | [reference/advanced.md](reference/advanced.md) | `@searchable` |
|
||||
| **Upserting Data** | [reference/operations.md](reference/operations.md) | `_upsert` mutations |
|
||||
| **Complex Filters** | [reference/operations.md](reference/operations.md) | `_or`, `_and`, `_not`, `eq`, `contains` |
|
||||
| **Transactions** | [reference/operations.md](reference/operations.md) | `@transaction`, `response` binding |
|
||||
| **Environment Config** | [reference/config.md](reference/config.md) | `dataconnect.yaml`, `connector.yaml` |
|
||||
|
||||
---
|
||||
|
||||
## Deployment & CLI
|
||||
|
||||
> **Read [reference/config.md](reference/config.md)** for deep dive on configuration.
|
||||
|
||||
Common commands (run from project root):
|
||||
|
||||
```bash
|
||||
# Initialize Data Connect
|
||||
npx -y firebase-tools@latest init dataconnect
|
||||
|
||||
# Start local emulator
|
||||
npx -y firebase-tools@latest emulators:start --only dataconnect
|
||||
|
||||
# Generate SDK code
|
||||
npx -y firebase-tools@latest dataconnect:sdk:generate
|
||||
|
||||
# Deploy to production
|
||||
npx -y firebase-tools@latest deploy --only dataconnect
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
For complete, working code examples of schemas and operations, see **[examples.md](examples.md)**.
|
||||
@@ -0,0 +1,377 @@
|
||||
# Examples
|
||||
|
||||
Complete, working examples for common Data Connect use cases.
|
||||
|
||||
---
|
||||
|
||||
## Movie Review App
|
||||
|
||||
A complete schema for a movie database with reviews, actors, and user authentication.
|
||||
|
||||
### Schema
|
||||
|
||||
```graphql
|
||||
# schema.gql
|
||||
|
||||
# Users
|
||||
type User @table(key: "uid") {
|
||||
uid: String! @default(expr: "auth.uid")
|
||||
email: String! @unique
|
||||
displayName: String
|
||||
createdAt: Timestamp! @default(expr: "request.time")
|
||||
}
|
||||
|
||||
# Movies
|
||||
type Movie @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
title: String!
|
||||
releaseYear: Int
|
||||
genre: String @index
|
||||
rating: Float
|
||||
description: String
|
||||
posterUrl: String
|
||||
createdAt: Timestamp! @default(expr: "request.time")
|
||||
}
|
||||
|
||||
# Movie metadata (one-to-one)
|
||||
type MovieMetadata @table {
|
||||
movie: Movie! @unique
|
||||
director: String
|
||||
runtime: Int
|
||||
budget: Int64
|
||||
}
|
||||
|
||||
# Actors
|
||||
type Actor @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
name: String!
|
||||
birthDate: Date
|
||||
}
|
||||
|
||||
# Movie-Actor relationship (many-to-many)
|
||||
type MovieActor @table(key: ["movie", "actor"]) {
|
||||
movie: Movie!
|
||||
actor: Actor!
|
||||
role: String! # "lead" or "supporting"
|
||||
character: String
|
||||
}
|
||||
|
||||
# Reviews (user-owned)
|
||||
type Review @table @unique(fields: ["movie", "user"]) {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
movie: Movie!
|
||||
user: User!
|
||||
rating: Int!
|
||||
text: String
|
||||
createdAt: Timestamp! @default(expr: "request.time")
|
||||
}
|
||||
```
|
||||
|
||||
### Queries
|
||||
|
||||
```graphql
|
||||
# queries.gql
|
||||
|
||||
# Public: List movies with filtering
|
||||
query ListMovies($genre: String, $minRating: Float, $limit: Int)
|
||||
@auth(level: PUBLIC) {
|
||||
movies(
|
||||
where: {
|
||||
genre: { eq: $genre },
|
||||
rating: { ge: $minRating }
|
||||
},
|
||||
orderBy: [{ rating: DESC }],
|
||||
limit: $limit
|
||||
) {
|
||||
id title genre rating releaseYear posterUrl
|
||||
}
|
||||
}
|
||||
|
||||
# Public: Get movie with full details
|
||||
query GetMovie($id: UUID!) @auth(level: PUBLIC) {
|
||||
movie(id: $id) {
|
||||
id title genre rating releaseYear description
|
||||
metadata: movieMetadata_on_movie { director runtime }
|
||||
actors: actors_via_MovieActor { name }
|
||||
reviews: reviews_on_movie(orderBy: [{ createdAt: DESC }], limit: 10) {
|
||||
rating text createdAt
|
||||
user { displayName }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# User: Get my reviews
|
||||
query MyReviews @auth(level: USER) {
|
||||
reviews(where: { user: { uid: { eq_expr: "auth.uid" }}}) {
|
||||
id rating text createdAt
|
||||
movie { id title posterUrl }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mutations
|
||||
|
||||
```graphql
|
||||
# mutations.gql
|
||||
|
||||
# User: Create/update profile on first login
|
||||
mutation UpsertUser($email: String!, $displayName: String) @auth(level: USER) {
|
||||
user_upsert(data: {
|
||||
uid_expr: "auth.uid",
|
||||
email: $email,
|
||||
displayName: $displayName
|
||||
})
|
||||
}
|
||||
|
||||
# User: Add review (one per movie per user)
|
||||
mutation AddReview($movieId: UUID!, $rating: Int!, $text: String)
|
||||
@auth(level: USER) {
|
||||
review_upsert(data: {
|
||||
movie: { id: $movieId },
|
||||
user: { uid_expr: "auth.uid" },
|
||||
rating: $rating,
|
||||
text: $text
|
||||
})
|
||||
}
|
||||
|
||||
# User: Delete my review
|
||||
mutation DeleteReview($id: UUID!) @auth(level: USER) {
|
||||
review_delete(
|
||||
first: { where: {
|
||||
id: { eq: $id },
|
||||
user: { uid: { eq_expr: "auth.uid" }}
|
||||
}}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E-Commerce Store
|
||||
|
||||
Products, orders, and cart management with user authentication.
|
||||
|
||||
### Schema
|
||||
|
||||
```graphql
|
||||
# schema.gql
|
||||
|
||||
type User @table(key: "uid") {
|
||||
uid: String! @default(expr: "auth.uid")
|
||||
email: String! @unique
|
||||
name: String
|
||||
shippingAddress: String
|
||||
}
|
||||
|
||||
type Product @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
name: String! @index
|
||||
description: String
|
||||
price: Float!
|
||||
stock: Int! @default(value: 0)
|
||||
category: String @index
|
||||
imageUrl: String
|
||||
}
|
||||
|
||||
type CartItem @table(key: ["user", "product"]) {
|
||||
user: User!
|
||||
product: Product!
|
||||
quantity: Int!
|
||||
}
|
||||
|
||||
enum OrderStatus {
|
||||
PENDING
|
||||
PAID
|
||||
SHIPPED
|
||||
DELIVERED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
type Order @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
user: User!
|
||||
status: OrderStatus! @default(value: PENDING)
|
||||
total: Float!
|
||||
shippingAddress: String!
|
||||
createdAt: Timestamp! @default(expr: "request.time")
|
||||
}
|
||||
|
||||
type OrderItem @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
order: Order!
|
||||
product: Product!
|
||||
quantity: Int!
|
||||
priceAtPurchase: Float!
|
||||
}
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
```graphql
|
||||
# Public: Browse products
|
||||
query ListProducts($category: String, $search: String) @auth(level: PUBLIC) {
|
||||
products(where: {
|
||||
category: { eq: $category },
|
||||
name: { contains: $search },
|
||||
stock: { gt: 0 }
|
||||
}) {
|
||||
id name price stock imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
# User: View cart
|
||||
query MyCart @auth(level: USER) {
|
||||
cartItems(where: { user: { uid: { eq_expr: "auth.uid" }}}) {
|
||||
quantity
|
||||
product { id name price imageUrl stock }
|
||||
}
|
||||
}
|
||||
|
||||
# User: Add to cart
|
||||
mutation AddToCart($productId: UUID!, $quantity: Int!) @auth(level: USER) {
|
||||
cartItem_upsert(data: {
|
||||
user: { uid_expr: "auth.uid" },
|
||||
product: { id: $productId },
|
||||
quantity: $quantity
|
||||
})
|
||||
}
|
||||
|
||||
# User: Checkout (transactional)
|
||||
mutation Checkout($shippingAddress: String!)
|
||||
@auth(level: USER)
|
||||
@transaction {
|
||||
# Query cart items
|
||||
query @redact {
|
||||
cartItems(where: { user: { uid: { eq_expr: "auth.uid" }}})
|
||||
@check(expr: "this.size() > 0", message: "Cart is empty") {
|
||||
quantity
|
||||
product { id price }
|
||||
}
|
||||
}
|
||||
# Create order (in real app, calculate total from cart)
|
||||
order_insert(data: {
|
||||
user: { uid_expr: "auth.uid" },
|
||||
shippingAddress: $shippingAddress,
|
||||
total: 0 # Calculate in app logic
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blog with Permissions
|
||||
|
||||
Multi-author blog with role-based permissions.
|
||||
|
||||
### Schema
|
||||
|
||||
```graphql
|
||||
# schema.gql
|
||||
|
||||
type User @table(key: "uid") {
|
||||
uid: String! @default(expr: "auth.uid")
|
||||
email: String! @unique
|
||||
name: String!
|
||||
bio: String
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
VIEWER
|
||||
AUTHOR
|
||||
EDITOR
|
||||
ADMIN
|
||||
}
|
||||
|
||||
type BlogPermission @table(key: ["user"]) {
|
||||
user: User!
|
||||
role: UserRole! @default(value: VIEWER)
|
||||
}
|
||||
|
||||
enum PostStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
type Post @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
author: User!
|
||||
title: String! @searchable
|
||||
content: String! @searchable
|
||||
status: PostStatus! @default(value: DRAFT)
|
||||
publishedAt: Timestamp
|
||||
createdAt: Timestamp! @default(expr: "request.time")
|
||||
updatedAt: Timestamp! @default(expr: "request.time")
|
||||
}
|
||||
|
||||
type Comment @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
post: Post!
|
||||
author: User!
|
||||
content: String!
|
||||
createdAt: Timestamp! @default(expr: "request.time")
|
||||
}
|
||||
```
|
||||
|
||||
### Operations with Role Checks
|
||||
|
||||
```graphql
|
||||
# Public: Read published posts
|
||||
query PublishedPosts @auth(level: PUBLIC) {
|
||||
posts(
|
||||
where: { status: { eq: PUBLISHED }},
|
||||
orderBy: [{ publishedAt: DESC }]
|
||||
) {
|
||||
id title content publishedAt
|
||||
author { name }
|
||||
}
|
||||
}
|
||||
|
||||
# Author+: Create post
|
||||
mutation CreatePost($title: String!, $content: String!)
|
||||
@auth(level: USER)
|
||||
@transaction {
|
||||
# Check user is at least AUTHOR
|
||||
query @redact {
|
||||
blogPermission(key: { user: { uid_expr: "auth.uid" }})
|
||||
@check(expr: "this != null", message: "No permission record") {
|
||||
role @check(expr: "this in ['AUTHOR', 'EDITOR', 'ADMIN']", message: "Must be author+")
|
||||
}
|
||||
}
|
||||
post_insert(data: {
|
||||
author: { uid_expr: "auth.uid" },
|
||||
title: $title,
|
||||
content: $content
|
||||
})
|
||||
}
|
||||
|
||||
# Editor+: Publish any post
|
||||
mutation PublishPost($id: UUID!)
|
||||
@auth(level: USER)
|
||||
@transaction {
|
||||
query @redact {
|
||||
blogPermission(key: { user: { uid_expr: "auth.uid" }}) {
|
||||
role @check(expr: "this in ['EDITOR', 'ADMIN']", message: "Must be editor+")
|
||||
}
|
||||
}
|
||||
post_update(id: $id, data: {
|
||||
status: PUBLISHED,
|
||||
publishedAt_expr: "request.time"
|
||||
})
|
||||
}
|
||||
|
||||
# Admin: Grant role
|
||||
mutation GrantRole($userUid: String!, $role: UserRole!)
|
||||
@auth(level: USER)
|
||||
@transaction {
|
||||
query @redact {
|
||||
blogPermission(key: { user: { uid_expr: "auth.uid" }}) {
|
||||
role @check(expr: "this == 'ADMIN'", message: "Must be admin")
|
||||
}
|
||||
}
|
||||
blogPermission_upsert(data: {
|
||||
user: { uid: $userUid },
|
||||
role: $role
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,303 @@
|
||||
# Advanced Features Reference
|
||||
|
||||
## Contents
|
||||
- [Vector Similarity Search](#vector-similarity-search)
|
||||
- [Full-Text Search](#full-text-search)
|
||||
- [Cloud Functions Integration](#cloud-functions-integration)
|
||||
- [Data Seeding & Bulk Operations](#data-seeding--bulk-operations)
|
||||
|
||||
---
|
||||
|
||||
## Vector Similarity Search
|
||||
|
||||
Semantic search using Vertex AI embeddings and PostgreSQL's `pgvector`.
|
||||
|
||||
### Schema Setup
|
||||
|
||||
```graphql
|
||||
type Movie @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
title: String!
|
||||
description: String
|
||||
# Vector field for embeddings - size must match model output (768 for gecko)
|
||||
descriptionEmbedding: Vector! @col(size: 768)
|
||||
}
|
||||
```
|
||||
|
||||
### Generate Embeddings in Mutations
|
||||
|
||||
Use `_embed` server value to auto-generate embeddings via Vertex AI:
|
||||
|
||||
```graphql
|
||||
mutation CreateMovieWithEmbedding($title: String!, $description: String!)
|
||||
@auth(level: USER) {
|
||||
movie_insert(data: {
|
||||
title: $title,
|
||||
description: $description,
|
||||
descriptionEmbedding_embed: {
|
||||
model: "textembedding-gecko@003",
|
||||
text: $description
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Similarity Search Query
|
||||
|
||||
Data Connect generates `_similarity` fields for Vector columns:
|
||||
|
||||
```graphql
|
||||
query SearchMovies($query: String!) @auth(level: PUBLIC) {
|
||||
movies_descriptionEmbedding_similarity(
|
||||
compare_embed: { model: "textembedding-gecko@003", text: $query },
|
||||
method: L2, # L2, COSINE, or INNER_PRODUCT
|
||||
within: 2.0, # Max distance threshold
|
||||
limit: 5
|
||||
) {
|
||||
id
|
||||
title
|
||||
description
|
||||
_metadata { distance } # See how close each result is
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Similarity Parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `compare` | Raw Vector to compare against |
|
||||
| `compare_embed` | Generate embedding from text via Vertex AI |
|
||||
| `method` | Distance function: `L2`, `COSINE`, `INNER_PRODUCT` |
|
||||
| `within` | Max distance (results further are excluded) |
|
||||
| `where` | Additional filters |
|
||||
| `limit` | Max results to return |
|
||||
|
||||
### Custom Embeddings
|
||||
|
||||
Pass pre-computed vectors directly:
|
||||
|
||||
```graphql
|
||||
mutation StoreCustomEmbedding($id: UUID!, $embedding: Vector!) @auth(level: USER) {
|
||||
movie_update(id: $id, data: { descriptionEmbedding: $embedding })
|
||||
}
|
||||
|
||||
query SearchWithCustomVector($vector: Vector!) @auth(level: PUBLIC) {
|
||||
movies_descriptionEmbedding_similarity(
|
||||
compare: $vector,
|
||||
method: COSINE,
|
||||
limit: 10
|
||||
) { id title }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full-Text Search
|
||||
|
||||
Fast keyword/phrase search using PostgreSQL's full-text capabilities.
|
||||
|
||||
### Enable with @searchable
|
||||
|
||||
```graphql
|
||||
type Movie @table {
|
||||
title: String! @searchable
|
||||
description: String @searchable(language: "english")
|
||||
genre: String @searchable
|
||||
}
|
||||
```
|
||||
|
||||
### Search Query
|
||||
|
||||
Data Connect generates `_search` fields:
|
||||
|
||||
```graphql
|
||||
query SearchMovies($query: String!) @auth(level: PUBLIC) {
|
||||
movies_search(
|
||||
query: $query,
|
||||
queryFormat: QUERY, # QUERY, PLAIN, PHRASE, or ADVANCED
|
||||
limit: 20
|
||||
) {
|
||||
id title description
|
||||
_metadata { relevance } # Relevance score
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Query Formats
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| `QUERY` | Web-style (default): quotes, AND, OR supported |
|
||||
| `PLAIN` | Match all words, any order |
|
||||
| `PHRASE` | Match exact phrase |
|
||||
| `ADVANCED` | Full tsquery syntax |
|
||||
|
||||
### Tuning Results
|
||||
|
||||
```graphql
|
||||
query SearchWithThreshold($query: String!) @auth(level: PUBLIC) {
|
||||
movies_search(
|
||||
query: $query,
|
||||
relevanceThreshold: 0.05, # Min relevance score
|
||||
where: { genre: { eq: "Action" }},
|
||||
orderBy: [{ releaseYear: DESC }]
|
||||
) { id title }
|
||||
}
|
||||
```
|
||||
|
||||
### Supported Languages
|
||||
|
||||
`english` (default), `french`, `german`, `spanish`, `italian`, `portuguese`, `dutch`, `danish`, `finnish`, `norwegian`, `swedish`, `russian`, `arabic`, `hindi`, `simple`
|
||||
|
||||
---
|
||||
|
||||
## Cloud Functions Integration
|
||||
|
||||
Trigger Cloud Functions when mutations execute.
|
||||
|
||||
### Basic Trigger (Node.js)
|
||||
|
||||
```typescript
|
||||
import { onMutationExecuted } from "firebase-functions/dataconnect";
|
||||
import { logger } from "firebase-functions";
|
||||
|
||||
export const onUserCreate = onMutationExecuted(
|
||||
{
|
||||
service: "myService",
|
||||
connector: "default",
|
||||
operation: "CreateUser",
|
||||
region: "us-central1" // Must match Data Connect location
|
||||
},
|
||||
(event) => {
|
||||
const variables = event.data.payload.variables;
|
||||
const returnedData = event.data.payload.data;
|
||||
|
||||
logger.info("User created:", returnedData);
|
||||
// Send welcome email, sync to analytics, etc.
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Basic Trigger (Python)
|
||||
|
||||
```python
|
||||
from firebase_functions import dataconnect_fn, logger
|
||||
|
||||
@dataconnect_fn.on_mutation_executed(
|
||||
service="myService",
|
||||
connector="default",
|
||||
operation="CreateUser"
|
||||
)
|
||||
def on_user_create(event: dataconnect_fn.Event):
|
||||
variables = event.data.payload.variables
|
||||
returned_data = event.data.payload.data
|
||||
logger.info("User created:", returned_data)
|
||||
```
|
||||
|
||||
### Event Data
|
||||
|
||||
```typescript
|
||||
// event.authType: "app_user" | "unauthenticated" | "admin"
|
||||
// event.authId: Firebase Auth UID (for app_user)
|
||||
// event.data.payload.variables: mutation input variables
|
||||
// event.data.payload.data: mutation response data
|
||||
// event.data.payload.errors: any errors that occurred
|
||||
```
|
||||
|
||||
### Filtering with Wildcards
|
||||
|
||||
```typescript
|
||||
// Trigger on all User* mutations
|
||||
export const onUserMutation = onMutationExecuted(
|
||||
{ operation: "User*" },
|
||||
(event) => { /* ... */ }
|
||||
);
|
||||
|
||||
// Capture operation name
|
||||
export const onAnyMutation = onMutationExecuted(
|
||||
{ service: "myService", operation: "{operationName}" },
|
||||
(event) => {
|
||||
console.log("Operation:", event.params.operationName);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **Data sync**: Replicate to Firestore, BigQuery, external APIs
|
||||
- **Notifications**: Send emails, push notifications on events
|
||||
- **Async workflows**: Image processing, data aggregation
|
||||
- **Audit logging**: Track all data changes
|
||||
|
||||
> ⚠️ **Avoid infinite loops**: Don't trigger mutations that would fire the same trigger. Use filters to exclude self-triggered events.
|
||||
|
||||
---
|
||||
|
||||
## Data Seeding & Bulk Operations
|
||||
|
||||
### Local Prototyping with _insertMany
|
||||
|
||||
```graphql
|
||||
mutation SeedMovies @transaction {
|
||||
movie_insertMany(data: [
|
||||
{ id: "uuid-1", title: "Movie 1", genre: "Action" },
|
||||
{ id: "uuid-2", title: "Movie 2", genre: "Drama" },
|
||||
{ id: "uuid-3", title: "Movie 3", genre: "Comedy" }
|
||||
])
|
||||
}
|
||||
```
|
||||
|
||||
### Reset Data with _upsertMany
|
||||
|
||||
```graphql
|
||||
mutation ResetData {
|
||||
movie_upsertMany(data: [
|
||||
{ id: "uuid-1", title: "Movie 1", genre: "Action" },
|
||||
{ id: "uuid-2", title: "Movie 2", genre: "Drama" }
|
||||
])
|
||||
}
|
||||
```
|
||||
|
||||
### Clear All Data
|
||||
|
||||
```graphql
|
||||
mutation ClearMovies {
|
||||
movie_deleteMany(all: true)
|
||||
}
|
||||
```
|
||||
|
||||
### Production: Admin SDK Bulk Operations
|
||||
|
||||
```typescript
|
||||
import { initializeApp } from 'firebase-admin/app';
|
||||
import { getDataConnect } from 'firebase-admin/data-connect';
|
||||
|
||||
const app = initializeApp();
|
||||
const dc = getDataConnect({ location: "us-central1", serviceId: "my-service" });
|
||||
|
||||
const movies = [
|
||||
{ id: "uuid-1", title: "Movie 1", genre: "Action" },
|
||||
{ id: "uuid-2", title: "Movie 2", genre: "Drama" }
|
||||
];
|
||||
|
||||
// Bulk insert
|
||||
await dc.insertMany("movie", movies);
|
||||
|
||||
// Bulk upsert
|
||||
await dc.upsertMany("movie", movies);
|
||||
|
||||
// Single operations
|
||||
await dc.insert("movie", movies[0]);
|
||||
await dc.upsert("movie", movies[0]);
|
||||
```
|
||||
|
||||
### Emulator Data Persistence
|
||||
|
||||
```bash
|
||||
# Export emulator data
|
||||
npx -y firebase-tools@latest emulators:export ./seed-data
|
||||
|
||||
# Start with saved data
|
||||
npx -y firebase-tools@latest emulators:start --only dataconnect --import=./seed-data
|
||||
```
|
||||
@@ -0,0 +1,267 @@
|
||||
# Configuration Reference
|
||||
|
||||
## Contents
|
||||
- [Project Structure](#project-structure)
|
||||
- [dataconnect.yaml](#dataconnectyaml)
|
||||
- [connector.yaml](#connectoryaml)
|
||||
- [Firebase CLI Commands](#firebase-cli-commands)
|
||||
- [Emulator](#emulator)
|
||||
- [Deployment](#deployment)
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
project-root/
|
||||
├── firebase.json # Firebase project config
|
||||
└── dataconnect/
|
||||
├── dataconnect.yaml # Service configuration
|
||||
├── schema/
|
||||
│ └── schema.gql # Data model (types, relationships)
|
||||
└── connector/
|
||||
├── connector.yaml # Connector config + SDK generation
|
||||
├── queries.gql # Query operations
|
||||
└── mutations.gql # Mutation operations (optional separate file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## dataconnect.yaml
|
||||
|
||||
Main Data Connect service configuration:
|
||||
|
||||
```yaml
|
||||
specVersion: "v1"
|
||||
serviceId: "my-service"
|
||||
location: "us-central1"
|
||||
schemaValidation: "STRICT" # or "COMPATIBLE"
|
||||
schema:
|
||||
source: "./schema"
|
||||
datasource:
|
||||
postgresql:
|
||||
database: "fdcdb"
|
||||
cloudSql:
|
||||
instanceId: "my-instance"
|
||||
connectorDirs: ["./connector"]
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `specVersion` | Always `"v1"` |
|
||||
| `serviceId` | Unique identifier for the service |
|
||||
| `location` | GCP region (us-central1, us-east4, europe-west1, etc.) |
|
||||
| `schemaValidation` | Deployment mode: `"STRICT"` (must match exactly) or `"COMPATIBLE"` (backward compatible) |
|
||||
| `schema.source` | Path to schema directory |
|
||||
| `schema.datasource` | PostgreSQL connection config |
|
||||
| `connectorDirs` | List of connector directories |
|
||||
|
||||
### Cloud SQL Configuration
|
||||
|
||||
```yaml
|
||||
schema:
|
||||
datasource:
|
||||
postgresql:
|
||||
database: "my-database" # Database name
|
||||
cloudSql:
|
||||
instanceId: "my-instance" # Cloud SQL instance ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## connector.yaml
|
||||
|
||||
Connector configuration and SDK generation:
|
||||
|
||||
```yaml
|
||||
connectorId: "default"
|
||||
generate:
|
||||
javascriptSdk:
|
||||
outputDir: "../web/src/lib/dataconnect"
|
||||
package: "@myapp/dataconnect"
|
||||
kotlinSdk:
|
||||
outputDir: "../android/app/src/main/kotlin/com/myapp/dataconnect"
|
||||
package: "com.myapp.dataconnect"
|
||||
swiftSdk:
|
||||
outputDir: "../ios/MyApp/DataConnect"
|
||||
```
|
||||
|
||||
### SDK Generation Options
|
||||
|
||||
| SDK | Fields |
|
||||
|-----|--------|
|
||||
| `javascriptSdk` | `outputDir`, `package` |
|
||||
| `kotlinSdk` | `outputDir`, `package` |
|
||||
| `swiftSdk` | `outputDir` |
|
||||
| `nodeAdminSdk` | `outputDir`, `package` (for Admin SDK) |
|
||||
|
||||
---
|
||||
|
||||
## Firebase CLI Commands
|
||||
|
||||
### Initialize Data Connect
|
||||
|
||||
```bash
|
||||
# Interactive setup
|
||||
npx -y firebase-tools@latest init dataconnect
|
||||
|
||||
# Set project
|
||||
npx -y firebase-tools@latest use <project-id>
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Start emulator
|
||||
npx -y firebase-tools@latest emulators:start --only dataconnect
|
||||
|
||||
# Start with database seed data
|
||||
npx -y firebase-tools@latest emulators:start --only dataconnect --import=./seed-data
|
||||
|
||||
# Generate SDKs
|
||||
npx -y firebase-tools@latest dataconnect:sdk:generate
|
||||
|
||||
# Watch for schema changes (auto-regenerate)
|
||||
npx -y firebase-tools@latest dataconnect:sdk:generate --watch
|
||||
```
|
||||
|
||||
### Schema Management
|
||||
|
||||
```bash
|
||||
# Compare local schema to production
|
||||
npx -y firebase-tools@latest dataconnect:sql:diff
|
||||
|
||||
|
||||
# Apply migration
|
||||
npx -y firebase-tools@latest dataconnect:sql:migrate
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
# Deploy Data Connect service
|
||||
npx -y firebase-tools@latest deploy --only dataconnect
|
||||
|
||||
# Deploy specific connector
|
||||
npx -y firebase-tools@latest deploy --only dataconnect:connector-id
|
||||
|
||||
# Deploy with schema migration
|
||||
npx -y firebase-tools@latest deploy --only dataconnect --force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Emulator
|
||||
|
||||
### Start Emulator
|
||||
|
||||
```bash
|
||||
npx -y firebase-tools@latest emulators:start --only dataconnect
|
||||
```
|
||||
|
||||
Default ports:
|
||||
- Data Connect: `9399`
|
||||
- PostgreSQL: `9939` (local PostgreSQL instance)
|
||||
|
||||
### Emulator Configuration (firebase.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"emulators": {
|
||||
"dataconnect": {
|
||||
"port": 9399
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Connect from SDK
|
||||
|
||||
```typescript
|
||||
// Web
|
||||
import { connectDataConnectEmulator } from 'firebase/data-connect';
|
||||
connectDataConnectEmulator(dc, 'localhost', 9399);
|
||||
|
||||
// Android
|
||||
connector.dataConnect.useEmulator("10.0.2.2", 9399)
|
||||
|
||||
// iOS
|
||||
connector.useEmulator(host: "localhost", port: 9399)
|
||||
|
||||
|
||||
```
|
||||
|
||||
### Seed Data
|
||||
|
||||
Create seed data files and import:
|
||||
|
||||
```bash
|
||||
# Export current emulator data
|
||||
npx -y firebase-tools@latest emulators:export ./seed-data
|
||||
|
||||
# Start with seed data
|
||||
npx -y firebase-tools@latest emulators:start --only dataconnect --import=./seed-data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deploy Workflow
|
||||
|
||||
1. **Test locally** with emulator
|
||||
2. **Generate SQL diff**: `npx -y firebase-tools@latest dataconnect:sql:diff`
|
||||
3. **Review migration**: Check breaking changes
|
||||
4. **Deploy**: `npx -y firebase-tools@latest deploy --only dataconnect`
|
||||
|
||||
### Schema Migrations
|
||||
|
||||
Data Connect auto-generates PostgreSQL migrations:
|
||||
|
||||
```bash
|
||||
# Preview migration
|
||||
npx -y firebase-tools@latest dataconnect:sql:diff
|
||||
|
||||
# Apply migration (interactive)
|
||||
npx -y firebase-tools@latest dataconnect:sql:migrate
|
||||
|
||||
# Force migration (non-interactive)
|
||||
npx -y firebase-tools@latest dataconnect:sql:migrate --force
|
||||
```
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
Some schema changes require special handling:
|
||||
- Removing required fields
|
||||
- Changing field types
|
||||
- Removing tables
|
||||
|
||||
Use `--force` flag to acknowledge breaking changes during deploy.
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
```yaml
|
||||
# GitHub Actions example
|
||||
- name: Deploy Data Connect
|
||||
run: |
|
||||
npx -y firebase-tools@latest deploy --only dataconnect --token ${{ secrets.FIREBASE_TOKEN }} --force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VS Code Extension
|
||||
|
||||
Install "Firebase Data Connect" extension for:
|
||||
- Schema intellisense and validation
|
||||
- GraphQL operation testing
|
||||
- Emulator integration
|
||||
- SDK generation on save
|
||||
|
||||
### Extension Settings
|
||||
|
||||
```json
|
||||
{
|
||||
"firebase.dataConnect.autoGenerateSdk": true,
|
||||
"firebase.dataConnect.emulator.port": 9399
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,357 @@
|
||||
# Operations Reference
|
||||
|
||||
## Contents
|
||||
- [Generated Fields](#generated-fields)
|
||||
- [Queries](#queries)
|
||||
- [Mutations](#mutations)
|
||||
- [Key Scalars](#key-scalars)
|
||||
- [Multi-Step Operations](#multi-step-operations)
|
||||
|
||||
---
|
||||
|
||||
## Generated Fields
|
||||
|
||||
Data Connect auto-generates fields for each `@table` type:
|
||||
|
||||
| Generated Field | Purpose | Example |
|
||||
|-----------------|---------|---------|
|
||||
| `movie(id: UUID, key: Key, first: Row)` | Get single record | `movie(id: $id)` or `movie(first: {where: ...})` |
|
||||
| `movies(where: ..., orderBy: ..., limit: ..., offset: ..., distinct: ..., having: ...)` | List/filter records | `movies(where: {...})` |
|
||||
| `movie_insert(data: ...)` | Create record | Returns key |
|
||||
| `movie_insertMany(data: [...])` | Bulk create | Returns keys |
|
||||
| `movie_update(id: ..., data: ...)` | Update by ID | Returns key or null |
|
||||
| `movie_updateMany(where: ..., data: ...)` | Bulk update | Returns count |
|
||||
| `movie_upsert(data: ...)` | Insert or update | Returns key |
|
||||
| `movie_delete(id: ...)` | Delete by ID | Returns key or null |
|
||||
| `movie_deleteMany(where: ...)` | Bulk delete | Returns count |
|
||||
|
||||
### Relation Fields
|
||||
For a `Post` with `author: User!`:
|
||||
- `post.author` - Navigate to related User
|
||||
- `user.posts_on_author` - Reverse: all Posts by User
|
||||
|
||||
For many-to-many via `MovieActor`:
|
||||
- `movie.actors_via_MovieActor` - Get all actors
|
||||
- `actor.movies_via_MovieActor` - Get all movies
|
||||
|
||||
---
|
||||
|
||||
## Queries
|
||||
|
||||
### Basic Query
|
||||
|
||||
```graphql
|
||||
query GetMovie($id: UUID!) @auth(level: PUBLIC) {
|
||||
movie(id: $id) {
|
||||
id title genre releaseYear
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### List with Filtering
|
||||
|
||||
```graphql
|
||||
query ListMovies($genre: String, $minRating: Int) @auth(level: PUBLIC) {
|
||||
movies(
|
||||
where: {
|
||||
genre: { eq: $genre },
|
||||
rating: { ge: $minRating }
|
||||
},
|
||||
orderBy: [{ releaseYear: DESC }, { title: ASC }],
|
||||
limit: 20,
|
||||
offset: 0
|
||||
) {
|
||||
id title genre rating
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Filter Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `eq` | Equals | `{ title: { eq: "Matrix" }}` |
|
||||
| `ne` | Not equals | `{ status: { ne: "deleted" }}` |
|
||||
| `gt`, `ge` | Greater than (or equal) | `{ rating: { ge: 4 }}` |
|
||||
| `lt`, `le` | Less than (or equal) | `{ releaseYear: { lt: 2000 }}` |
|
||||
| `in` | In list | `{ genre: { in: ["Action", "Drama"] }}` |
|
||||
| `nin` | Not in list | `{ status: { nin: ["deleted", "hidden"] }}` |
|
||||
| `isNull` | Is null check | `{ description: { isNull: true }}` |
|
||||
| `contains` | String contains | `{ title: { contains: "war" }}` |
|
||||
| `startsWith` | String starts with | `{ title: { startsWith: "The" }}` |
|
||||
| `endsWith` | String ends with | `{ email: { endsWith: "@gmail.com" }}` |
|
||||
| `includes` | Array includes | `{ tags: { includes: "sci-fi" }}` |
|
||||
|
||||
### Expression Operators (Compare with Server Values)
|
||||
|
||||
Use `_expr` suffix to compare with server-side values:
|
||||
|
||||
```graphql
|
||||
query MyPosts @auth(level: USER) {
|
||||
posts(where: { authorUid: { eq_expr: "auth.uid" }}) {
|
||||
id title
|
||||
}
|
||||
}
|
||||
|
||||
query RecentPosts @auth(level: PUBLIC) {
|
||||
posts(where: { publishedAt: { lt_expr: "request.time" }}) {
|
||||
id title
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Logical Operators
|
||||
|
||||
```graphql
|
||||
query ComplexFilter($genre: String, $minRating: Int) @auth(level: PUBLIC) {
|
||||
movies(where: {
|
||||
_or: [
|
||||
{ genre: { eq: $genre }},
|
||||
{ rating: { ge: $minRating }}
|
||||
],
|
||||
_and: [
|
||||
{ releaseYear: { ge: 2000 }},
|
||||
{ status: { ne: "hidden" }}
|
||||
],
|
||||
_not: { genre: { eq: "Horror" }}
|
||||
}) { id title }
|
||||
}
|
||||
```
|
||||
|
||||
### Relational Queries
|
||||
|
||||
```graphql
|
||||
# Navigate relationships
|
||||
query MovieWithDetails($id: UUID!) @auth(level: PUBLIC) {
|
||||
movie(id: $id) {
|
||||
title
|
||||
# One-to-one
|
||||
metadata: movieMetadata_on_movie { director }
|
||||
# One-to-many
|
||||
reviews: reviews_on_movie { rating user { name }}
|
||||
# Many-to-many
|
||||
actors: actors_via_MovieActor { name }
|
||||
}
|
||||
}
|
||||
|
||||
# Filter by related data
|
||||
query MoviesByDirector($director: String!) @auth(level: PUBLIC) {
|
||||
movies(where: {
|
||||
movieMetadata_on_movie: { director: { eq: $director }}
|
||||
}) { id title }
|
||||
}
|
||||
```
|
||||
|
||||
### Aliases
|
||||
|
||||
```graphql
|
||||
query CompareRatings($genre: String!) @auth(level: PUBLIC) {
|
||||
highRated: movies(where: { genre: { eq: $genre }, rating: { ge: 8 }}) {
|
||||
title rating
|
||||
}
|
||||
lowRated: movies(where: { genre: { eq: $genre }, rating: { lt: 5 }}) {
|
||||
title rating
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mutations
|
||||
|
||||
### Create
|
||||
|
||||
```graphql
|
||||
mutation CreateMovie($title: String!, $genre: String) @auth(level: USER) {
|
||||
movie_insert(data: {
|
||||
title: $title,
|
||||
genre: $genre
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Create with Server Values
|
||||
|
||||
```graphql
|
||||
mutation CreatePost($title: String!, $content: String!) @auth(level: USER) {
|
||||
post_insert(data: {
|
||||
authorUid_expr: "auth.uid", # Current user
|
||||
id_expr: "uuidV4()", # Auto-generate UUID
|
||||
createdAt_expr: "request.time", # Server timestamp
|
||||
title: $title,
|
||||
content: $content
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```graphql
|
||||
mutation UpdateMovie($id: UUID!, $title: String, $genre: String) @auth(level: USER) {
|
||||
movie_update(
|
||||
id: $id,
|
||||
data: {
|
||||
title: $title,
|
||||
genre: $genre,
|
||||
updatedAt_expr: "request.time"
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Update Operators
|
||||
|
||||
```graphql
|
||||
mutation IncrementViews($id: UUID!) @auth(level: PUBLIC) {
|
||||
movie_update(id: $id, data: {
|
||||
viewCount_update: { inc: 1 }
|
||||
})
|
||||
}
|
||||
|
||||
mutation AddTag($id: UUID!, $tag: String!) @auth(level: USER) {
|
||||
movie_update(id: $id, data: {
|
||||
tags_update: { add: [$tag] } # add, remove, append, prepend
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
| Operator | Types | Description |
|
||||
|----------|-------|-------------|
|
||||
| `inc` | Int, Float, Date, Timestamp | Increment value |
|
||||
| `dec` | Int, Float, Date, Timestamp | Decrement value |
|
||||
| `add` | Lists | Add items if not present |
|
||||
| `remove` | Lists | Remove all matching items |
|
||||
| `append` | Lists | Append to end |
|
||||
| `prepend` | Lists | Prepend to start |
|
||||
|
||||
### Upsert
|
||||
|
||||
```graphql
|
||||
mutation UpsertUser($email: String!, $name: String!) @auth(level: USER) {
|
||||
user_upsert(data: {
|
||||
uid_expr: "auth.uid",
|
||||
email: $email,
|
||||
name: $name
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Delete
|
||||
|
||||
```graphql
|
||||
mutation DeleteMovie($id: UUID!) @auth(level: USER) {
|
||||
movie_delete(id: $id)
|
||||
}
|
||||
|
||||
mutation DeleteOldDrafts @auth(level: USER) {
|
||||
post_deleteMany(where: {
|
||||
status: { eq: "draft" },
|
||||
createdAt: { lt_time: { now: true, sub: { days: 30 }}}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Filtered Updates/Deletes (User-Owned)
|
||||
|
||||
```graphql
|
||||
mutation UpdateMyPost($id: UUID!, $content: String!) @auth(level: USER) {
|
||||
post_update(
|
||||
first: { where: {
|
||||
id: { eq: $id },
|
||||
authorUid: { eq_expr: "auth.uid" } # Only own posts
|
||||
}},
|
||||
data: { content: $content }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Scalars
|
||||
|
||||
Key scalars (`Movie_Key`, `User_Key`) are auto-generated types representing primary keys:
|
||||
|
||||
```graphql
|
||||
# Using key scalar
|
||||
query GetMovie($key: Movie_Key!) @auth(level: PUBLIC) {
|
||||
movie(key: $key) { title }
|
||||
}
|
||||
|
||||
# Variable format
|
||||
# { "key": { "id": "uuid-here" } }
|
||||
|
||||
# Composite key
|
||||
# { "key": { "movieId": "...", "userId": "..." } }
|
||||
```
|
||||
|
||||
Key scalars are returned by mutations:
|
||||
|
||||
```graphql
|
||||
mutation CreateAndFetch($title: String!) @auth(level: USER) {
|
||||
key: movie_insert(data: { title: $title })
|
||||
# Returns: { "key": { "id": "generated-uuid" } }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Step Operations
|
||||
|
||||
### @transaction
|
||||
|
||||
Ensures atomicity - all steps succeed or all rollback:
|
||||
|
||||
```graphql
|
||||
mutation CreateUserWithProfile($name: String!, $bio: String!)
|
||||
@auth(level: USER)
|
||||
@transaction {
|
||||
# Step 1: Create user
|
||||
user_insert(data: {
|
||||
uid_expr: "auth.uid",
|
||||
name: $name
|
||||
})
|
||||
# Step 2: Create profile (uses response from step 1)
|
||||
userProfile_insert(data: {
|
||||
userId_expr: "response.user_insert.uid",
|
||||
bio: $bio
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Using response Binding
|
||||
|
||||
Access results from previous steps:
|
||||
|
||||
```graphql
|
||||
mutation CreateTodoWithItem($listName: String!, $itemText: String!)
|
||||
@auth(level: USER)
|
||||
@transaction {
|
||||
todoList_insert(data: {
|
||||
id_expr: "uuidV4()",
|
||||
name: $listName
|
||||
})
|
||||
todoItem_insert(data: {
|
||||
listId_expr: "response.todoList_insert.id", # From previous step
|
||||
text: $itemText
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Embedded Queries
|
||||
|
||||
Run queries within mutations for validation:
|
||||
|
||||
```graphql
|
||||
mutation AddToPublicList($listId: UUID!, $item: String!)
|
||||
@auth(level: USER)
|
||||
@transaction {
|
||||
# Step 1: Verify list exists and is public
|
||||
query @redact {
|
||||
todoList(id: $listId) @check(expr: "this != null", message: "List not found") {
|
||||
isPublic @check(expr: "this == true", message: "List is not public")
|
||||
}
|
||||
}
|
||||
# Step 2: Add item
|
||||
todoItem_insert(data: { listId: $listId, text: $item })
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,278 @@
|
||||
# Schema Reference
|
||||
|
||||
## Contents
|
||||
- [Defining Types](#defining-types)
|
||||
- [Core Directives](#core-directives)
|
||||
- [Relationships](#relationships)
|
||||
- [Data Types](#data-types)
|
||||
- [Enumerations](#enumerations)
|
||||
|
||||
---
|
||||
|
||||
## Defining Types
|
||||
|
||||
Types with `@table` map to PostgreSQL tables. Data Connect auto-generates an implicit `id: UUID!` primary key.
|
||||
|
||||
```graphql
|
||||
type Movie @table {
|
||||
# id: UUID! is auto-added
|
||||
title: String!
|
||||
releaseYear: Int
|
||||
genre: String
|
||||
}
|
||||
```
|
||||
|
||||
### Customizing Tables
|
||||
|
||||
```graphql
|
||||
type Movie @table(name: "movies", key: "id", singular: "movie", plural: "movies") {
|
||||
id: UUID! @col(name: "movie_id") @default(expr: "uuidV4()")
|
||||
title: String!
|
||||
releaseYear: Int @col(name: "release_year")
|
||||
genre: String @col(dataType: "varchar(20)")
|
||||
}
|
||||
```
|
||||
|
||||
### User Table with Auth
|
||||
|
||||
```graphql
|
||||
type User @table(key: "uid") {
|
||||
uid: String! @default(expr: "auth.uid")
|
||||
email: String! @unique
|
||||
displayName: String @col(dataType: "varchar(100)")
|
||||
createdAt: Timestamp! @default(expr: "request.time")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Directives
|
||||
|
||||
### @table
|
||||
Defines a database table.
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `name` | PostgreSQL table name (snake_case default) |
|
||||
| `key` | Primary key field(s), default `["id"]` |
|
||||
| `singular` | Singular name for generated fields |
|
||||
| `plural` | Plural name for generated fields |
|
||||
|
||||
### @col
|
||||
Customizes column mapping.
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `name` | Column name in PostgreSQL |
|
||||
| `dataType` | PostgreSQL type: `serial`, `varchar(n)`, `text`, etc. |
|
||||
| `size` | Required for `Vector` type |
|
||||
|
||||
### @default
|
||||
Sets default value for inserts.
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `value` | Literal value: `@default(value: "draft")` |
|
||||
| `expr` | CEL expression: `@default(expr: "uuidV4()")`, `@default(expr: "auth.uid")`, `@default(expr: "request.time")` |
|
||||
| `sql` | Raw SQL: `@default(sql: "now()")` |
|
||||
|
||||
**Common expressions:**
|
||||
- `uuidV4()` - Generate UUID
|
||||
- `auth.uid` - Current user's Firebase Auth UID
|
||||
- `request.time` - Server timestamp
|
||||
|
||||
### @unique
|
||||
Adds unique constraint.
|
||||
|
||||
```graphql
|
||||
type User @table {
|
||||
email: String! @unique
|
||||
}
|
||||
|
||||
# Composite unique
|
||||
type Review @table @unique(fields: ["movie", "user"]) {
|
||||
movie: Movie!
|
||||
user: User!
|
||||
rating: Int
|
||||
}
|
||||
```
|
||||
|
||||
### @index
|
||||
Creates database index for query performance.
|
||||
|
||||
```graphql
|
||||
type Movie @table @index(fields: ["genre", "releaseYear"], order: [ASC, DESC]) {
|
||||
title: String! @index
|
||||
genre: String
|
||||
releaseYear: Int
|
||||
}
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `fields` | Fields for composite index (on @table) |
|
||||
| `order` | `[ASC]` or `[DESC]` for each field |
|
||||
| `type` | `BTREE` (default), `GIN` (arrays), `HNSW`/`IVFFLAT` (vectors) |
|
||||
|
||||
### @searchable
|
||||
Enables full-text search on String fields.
|
||||
|
||||
```graphql
|
||||
type Post @table {
|
||||
title: String! @searchable
|
||||
body: String! @searchable(language: "english")
|
||||
}
|
||||
|
||||
# Usage
|
||||
query SearchPosts($q: String!) @auth(level: PUBLIC) {
|
||||
posts_search(query: $q) { id title body }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationships
|
||||
|
||||
### One-to-Many (Implicit Foreign Key)
|
||||
|
||||
```graphql
|
||||
type Post @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
author: User! # Creates authorId foreign key
|
||||
title: String!
|
||||
}
|
||||
|
||||
type User @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
name: String!
|
||||
# Auto-generated: posts_on_author: [Post!]!
|
||||
}
|
||||
```
|
||||
|
||||
### @ref Directive
|
||||
Customizes foreign key reference.
|
||||
|
||||
```graphql
|
||||
type Post @table {
|
||||
author: User! @ref(fields: "authorId", references: "id")
|
||||
authorId: UUID! # Explicit FK field
|
||||
}
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `fields` | Local FK field name(s) |
|
||||
| `references` | Target field(s) in referenced table |
|
||||
| `constraintName` | PostgreSQL constraint name |
|
||||
|
||||
**Cascade behavior:**
|
||||
- Required reference (`User!`): CASCADE DELETE (post deleted when user deleted)
|
||||
- Optional reference (`User`): SET NULL (authorId set to null when user deleted)
|
||||
|
||||
### One-to-One
|
||||
|
||||
Use `@unique` on the reference field:
|
||||
|
||||
```graphql
|
||||
type User @table { id: UUID! name: String! }
|
||||
|
||||
type UserProfile @table {
|
||||
user: User! @unique # One profile per user
|
||||
bio: String
|
||||
avatarUrl: String
|
||||
}
|
||||
|
||||
# Query: user.userProfile_on_user
|
||||
```
|
||||
|
||||
### Many-to-Many
|
||||
|
||||
Use a join table with composite primary key:
|
||||
|
||||
```graphql
|
||||
type Movie @table { id: UUID! title: String! }
|
||||
type Actor @table { id: UUID! name: String! }
|
||||
|
||||
type MovieActor @table(key: ["movie", "actor"]) {
|
||||
movie: Movie!
|
||||
actor: Actor!
|
||||
role: String! # Extra data on relationship
|
||||
}
|
||||
|
||||
# Generated fields:
|
||||
# - movie.actors_via_MovieActor: [Actor!]!
|
||||
# - actor.movies_via_MovieActor: [Movie!]!
|
||||
# - movie.movieActors_on_movie: [MovieActor!]!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Types
|
||||
|
||||
| GraphQL Type | PostgreSQL Default | Other PostgreSQL Types |
|
||||
|--------------|-------------------|----------------------|
|
||||
| `String` | `text` | `varchar(n)`, `char(n)` |
|
||||
| `Int` | `int4` | `int2`, `serial` |
|
||||
| `Int64` | `bigint` | `bigserial`, `numeric` |
|
||||
| `Float` | `float8` | `float4`, `numeric` |
|
||||
| `Boolean` | `boolean` | |
|
||||
| `UUID` | `uuid` | |
|
||||
| `Date` | `date` | |
|
||||
| `Timestamp` | `timestamptz` | Stored as UTC |
|
||||
| `Any` | `jsonb` | |
|
||||
| `Vector` | `vector` | Requires `@col(size: N)` |
|
||||
| `[Type]` | Array | e.g., `[String]` → `text[]` |
|
||||
|
||||
---
|
||||
|
||||
## Enumerations
|
||||
|
||||
```graphql
|
||||
enum Status {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
type Post @table {
|
||||
status: Status! @default(value: DRAFT)
|
||||
allowedStatuses: [Status!]
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Enum names: PascalCase, no underscores
|
||||
- Enum values: UPPER_SNAKE_CASE
|
||||
- Values are ordered (for comparison operations)
|
||||
- Changing order or removing values is a breaking change
|
||||
|
||||
---
|
||||
|
||||
## Views (Advanced)
|
||||
|
||||
Map custom SQL queries to GraphQL types:
|
||||
|
||||
```graphql
|
||||
type MovieStats @view(sql: """
|
||||
SELECT
|
||||
movie_id,
|
||||
COUNT(*) as review_count,
|
||||
AVG(rating) as avg_rating
|
||||
FROM review
|
||||
GROUP BY movie_id
|
||||
""") {
|
||||
movie: Movie @unique
|
||||
reviewCount: Int
|
||||
avgRating: Float
|
||||
}
|
||||
|
||||
# Query movies with stats
|
||||
query TopMovies @auth(level: PUBLIC) {
|
||||
movies(orderBy: [{ rating: DESC }]) {
|
||||
title
|
||||
stats: movieStats_on_movie {
|
||||
reviewCount avgRating
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,287 @@
|
||||
# SDK Reference
|
||||
|
||||
## Contents
|
||||
- [SDK Generation](#sdk-generation)
|
||||
- [Web SDK](#web-sdk)
|
||||
- [Android SDK](#android-sdk)
|
||||
- [iOS SDK](#ios-sdk)
|
||||
- [Admin SDK](#admin-sdk)
|
||||
|
||||
---
|
||||
|
||||
## SDK Generation
|
||||
|
||||
Configure SDK generation in `connector.yaml`:
|
||||
|
||||
```yaml
|
||||
connectorId: my-connector
|
||||
generate:
|
||||
javascriptSdk:
|
||||
outputDir: "../web-app/src/lib/dataconnect"
|
||||
package: "@movie-app/dataconnect"
|
||||
kotlinSdk:
|
||||
outputDir: "../android-app/app/src/main/kotlin/com/example/dataconnect"
|
||||
package: "com.example.dataconnect"
|
||||
swiftSdk:
|
||||
outputDir: "../ios-app/DataConnect"
|
||||
```
|
||||
|
||||
Generate SDKs:
|
||||
```bash
|
||||
npx -y firebase-tools@latest dataconnect:sdk:generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web SDK
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install firebase
|
||||
```
|
||||
|
||||
### Initialization
|
||||
|
||||
```typescript
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getDataConnect, connectDataConnectEmulator } from 'firebase/data-connect';
|
||||
import { connectorConfig } from '@movie-app/dataconnect';
|
||||
|
||||
const app = initializeApp(firebaseConfig);
|
||||
const dc = getDataConnect(app, connectorConfig);
|
||||
|
||||
// For local development
|
||||
if (import.meta.env.DEV) {
|
||||
connectDataConnectEmulator(dc, 'localhost', 9399);
|
||||
}
|
||||
```
|
||||
|
||||
### Calling Operations
|
||||
|
||||
```typescript
|
||||
// Generated SDK provides typed functions
|
||||
import { listMovies, createMovie, getMovie } from '@movie-app/dataconnect';
|
||||
|
||||
// Accessing Nested Fields
|
||||
const movie = await getMovie({ id: '...' });
|
||||
// Relations are just properties on the object
|
||||
const director = movie.data.movie.metadata.director;
|
||||
const firstActor = movie.data.movie.actors[0].name;
|
||||
|
||||
// Query
|
||||
const result = await listMovies();
|
||||
console.log(result.data.movies);
|
||||
|
||||
// Query with variables
|
||||
const movie = await getMovie({ id: 'uuid-here' });
|
||||
|
||||
// Mutation
|
||||
const newMovie = await createMovie({
|
||||
title: 'New Movie',
|
||||
genre: 'Action'
|
||||
});
|
||||
console.log(newMovie.data.movie_insert); // Returns key
|
||||
```
|
||||
|
||||
### Subscriptions
|
||||
|
||||
```typescript
|
||||
import { listMoviesRef, subscribe } from '@movie-app/dataconnect';
|
||||
|
||||
const unsubscribe = subscribe(listMoviesRef(), {
|
||||
onNext: (result) => {
|
||||
console.log('Movies updated:', result.data.movies);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Subscription error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Later: unsubscribe();
|
||||
```
|
||||
|
||||
### With Authentication
|
||||
|
||||
```typescript
|
||||
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
|
||||
|
||||
const auth = getAuth(app);
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
|
||||
// SDK automatically includes auth token in requests
|
||||
const myReviews = await myReviews(); // @auth(level: USER) query from examples.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Android SDK
|
||||
|
||||
### Dependencies (build.gradle.kts)
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.0.0"))
|
||||
implementation("com.google.firebase:firebase-dataconnect")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.0")
|
||||
}
|
||||
```
|
||||
|
||||
### Initialization
|
||||
|
||||
```kotlin
|
||||
import com.google.firebase.Firebase
|
||||
import com.google.firebase.dataconnect.dataConnect
|
||||
import com.example.dataconnect.MyConnector
|
||||
|
||||
val connector = MyConnector.instance
|
||||
|
||||
// For emulator
|
||||
connector.dataConnect.useEmulator("10.0.2.2", 9399)
|
||||
```
|
||||
|
||||
### Calling Operations
|
||||
|
||||
```kotlin
|
||||
// Query
|
||||
val result = connector.listMovies.execute()
|
||||
result.data.movies.forEach { movie ->
|
||||
println(movie.title)
|
||||
// Access nested fields directly
|
||||
println(movie.metadata?.director)
|
||||
println(movie.actors.firstOrNull()?.name)
|
||||
}
|
||||
|
||||
// Query with variables
|
||||
val movie = connector.getMovie.execute(id = "uuid-here")
|
||||
|
||||
// Mutation
|
||||
val newMovie = connector.createMovie.execute(
|
||||
title = "New Movie",
|
||||
genre = "Action"
|
||||
)
|
||||
```
|
||||
|
||||
### Flow Subscription
|
||||
|
||||
```kotlin
|
||||
connector.listMovies.flow().collect { result ->
|
||||
when (result) {
|
||||
is DataConnectResult.Success -> updateUI(result.data.movies)
|
||||
is DataConnectResult.Error -> showError(result.exception)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## iOS SDK
|
||||
|
||||
### Dependencies (Package.swift or SPM)
|
||||
|
||||
```swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "11.0.0")
|
||||
]
|
||||
// Add FirebaseDataConnect to target dependencies
|
||||
```
|
||||
|
||||
### Initialization
|
||||
|
||||
```swift
|
||||
import FirebaseCore
|
||||
import FirebaseDataConnect
|
||||
|
||||
FirebaseApp.configure()
|
||||
let connector = MyConnector.shared
|
||||
|
||||
// For emulator
|
||||
connector.useEmulator(host: "localhost", port: 9399)
|
||||
```
|
||||
|
||||
### Calling Operations
|
||||
|
||||
```swift
|
||||
// Query
|
||||
let result = try await connector.listMovies.execute()
|
||||
for movie in result.data.movies {
|
||||
print(movie.title)
|
||||
// Access nested fields directly
|
||||
print(movie.metadata?.director ?? "Unknown")
|
||||
print(movie.actors.first?.name ?? "No actors")
|
||||
}
|
||||
|
||||
// Query with variables
|
||||
let movie = try await connector.getMovie.execute(id: "uuid-here")
|
||||
|
||||
// Mutation
|
||||
let newMovie = try await connector.createMovie.execute(
|
||||
title: "New Movie",
|
||||
genre: "Action"
|
||||
)
|
||||
```
|
||||
|
||||
### Combine Publisher
|
||||
|
||||
```swift
|
||||
connector.listMovies.publisher
|
||||
.sink(
|
||||
receiveCompletion: { completion in
|
||||
if case .failure(let error) = completion {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
},
|
||||
receiveValue: { result in
|
||||
self.movies = result.data.movies
|
||||
}
|
||||
)
|
||||
.store(in: &cancellables)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Admin SDK
|
||||
|
||||
Server-side operations with elevated privileges (bypasses @auth):
|
||||
|
||||
### Node.js
|
||||
|
||||
```typescript
|
||||
import { initializeApp, cert } from 'firebase-admin/app';
|
||||
import { getDataConnect } from 'firebase-admin/data-connect';
|
||||
|
||||
initializeApp({
|
||||
credential: cert(serviceAccount)
|
||||
});
|
||||
|
||||
const dc = getDataConnect();
|
||||
|
||||
// Execute operations (bypasses @auth)
|
||||
const result = await dc.executeGraphql({
|
||||
query: `query { users { id email } }`,
|
||||
operationName: 'ListAllUsers'
|
||||
});
|
||||
|
||||
// Or use generated Admin SDK
|
||||
import { listAllUsers } from './admin-connector';
|
||||
const users = await listAllUsers();
|
||||
```
|
||||
|
||||
### Generate Admin SDK
|
||||
|
||||
In `connector.yaml`:
|
||||
|
||||
```yaml
|
||||
generate:
|
||||
nodeAdminSdk:
|
||||
outputDir: "./admin-sdk"
|
||||
package: "@app/admin-dataconnect"
|
||||
```
|
||||
|
||||
Generate:
|
||||
```bash
|
||||
npx -y firebase-tools@latest dataconnect:sdk:generate
|
||||
```
|
||||
@@ -0,0 +1,289 @@
|
||||
# Security Reference
|
||||
|
||||
## Contents
|
||||
- [@auth Directive](#auth-directive)
|
||||
- [Access Levels](#access-levels)
|
||||
- [CEL Expressions](#cel-expressions)
|
||||
- [@check and @redact](#check-and-redact)
|
||||
- [Authorization Patterns](#authorization-patterns)
|
||||
- [Anti-Patterns](#anti-patterns)
|
||||
|
||||
---
|
||||
|
||||
## @auth Directive
|
||||
|
||||
Every deployable query/mutation must have `@auth`. Without it, operations default to `NO_ACCESS`.
|
||||
|
||||
```graphql
|
||||
query PublicData @auth(level: PUBLIC) { ... }
|
||||
query UserData @auth(level: USER) { ... }
|
||||
query AdminOnly @auth(expr: "auth.token.admin == true") { ... }
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `level` | Preset access level |
|
||||
| `expr` | CEL expression (alternative to level) |
|
||||
| `insecureReason` | Suppress deploy warning for PUBLIC/unfiltered USER |
|
||||
|
||||
---
|
||||
|
||||
## Access Levels
|
||||
|
||||
| Level | Who Can Access | CEL Equivalent |
|
||||
|-------|----------------|----------------|
|
||||
| `PUBLIC` | Anyone, authenticated or not | `true` |
|
||||
| `USER_ANON` | Any authenticated user (including anonymous) | `auth.uid != nil` |
|
||||
| `USER` | Authenticated users (excludes anonymous) | `auth.uid != nil && auth.token.firebase.sign_in_provider != 'anonymous'` |
|
||||
| `USER_EMAIL_VERIFIED` | Users with verified email | `auth.uid != nil && auth.token.email_verified` |
|
||||
| `NO_ACCESS` | Admin SDK only | `false` |
|
||||
|
||||
> **Important:** Levels like `USER` are starting points. Always add filters or expressions to verify the user can access specific data.
|
||||
|
||||
---
|
||||
|
||||
## CEL Expressions
|
||||
|
||||
### Available Bindings
|
||||
|
||||
| Binding | Description |
|
||||
|---------|-------------|
|
||||
| `auth.uid` | Current user's Firebase UID |
|
||||
| `auth.token` | Auth token claims (see below) |
|
||||
| `vars` | Operation variables (e.g., `vars.movieId`) |
|
||||
| `request.time` | Server timestamp |
|
||||
| `request.operationName` | "query" or "mutation" |
|
||||
|
||||
### auth.token Fields
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `email` | User's email address |
|
||||
| `email_verified` | Boolean: email verified |
|
||||
| `phone_number` | User's phone |
|
||||
| `name` | Display name |
|
||||
| `sub` | Firebase UID (same as auth.uid) |
|
||||
| `firebase.sign_in_provider` | `password`, `google.com`, `anonymous`, etc. |
|
||||
| `<custom_claim>` | Custom claims set via Admin SDK |
|
||||
|
||||
### Expression Examples
|
||||
|
||||
```graphql
|
||||
# Check custom claim
|
||||
@auth(expr: "auth.token.role == 'admin'")
|
||||
|
||||
# Check verified email domain
|
||||
@auth(expr: "auth.token.email_verified && auth.token.email.endsWith('@company.com')")
|
||||
|
||||
# Check multiple conditions
|
||||
@auth(expr: "auth.uid != nil && (auth.token.role == 'editor' || auth.token.role == 'admin')")
|
||||
|
||||
# Check variable
|
||||
@auth(expr: "has(vars.status) && vars.status in ['draft', 'published']")
|
||||
```
|
||||
|
||||
### Using eq_expr in Filters
|
||||
|
||||
Compare database fields with auth values:
|
||||
|
||||
```graphql
|
||||
query MyPosts @auth(level: USER) {
|
||||
posts(where: { authorUid: { eq_expr: "auth.uid" }}) {
|
||||
id title
|
||||
}
|
||||
}
|
||||
|
||||
mutation UpdateMyPost($id: UUID!, $title: String!) @auth(level: USER) {
|
||||
post_update(
|
||||
first: { where: {
|
||||
id: { eq: $id },
|
||||
authorUid: { eq_expr: "auth.uid" }
|
||||
}},
|
||||
data: { title: $title }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @check and @redact
|
||||
|
||||
Use `@check` to validate data and `@redact` to hide results from client:
|
||||
|
||||
### @check
|
||||
Validates a field value; aborts if check fails.
|
||||
|
||||
```graphql
|
||||
@check(expr: "this != null", message: "Not found")
|
||||
@check(expr: "this == 'editor'", message: "Must be editor")
|
||||
@check(expr: "this.exists(p, p.role == 'admin')", message: "No admin found")
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `expr` | CEL expression; `this` = current field value |
|
||||
| `message` | Error message if check fails |
|
||||
| `optional` | If `true`, pass when field not present |
|
||||
|
||||
### @redact
|
||||
Hides field from response (still evaluated for @check):
|
||||
|
||||
```graphql
|
||||
query @redact { ... } # Query result hidden but @check still runs
|
||||
```
|
||||
|
||||
### Authorization Data Lookup
|
||||
|
||||
Check database permissions before allowing mutation:
|
||||
|
||||
```graphql
|
||||
mutation UpdateMovie($id: UUID!, $title: String!)
|
||||
@auth(level: USER)
|
||||
@transaction {
|
||||
# Step 1: Check user has permission
|
||||
query @redact {
|
||||
moviePermission(
|
||||
key: { movieId: $id, userId_expr: "auth.uid" }
|
||||
) @check(expr: "this != null", message: "No access to movie") {
|
||||
role @check(expr: "this == 'editor'", message: "Must be editor")
|
||||
}
|
||||
}
|
||||
# Step 2: Update if authorized
|
||||
movie_update(id: $id, data: { title: $title })
|
||||
}
|
||||
```
|
||||
|
||||
### Validate Key Exists
|
||||
|
||||
```graphql
|
||||
mutation MustDeleteMovie($id: UUID!) @auth(level: USER) @transaction {
|
||||
movie_delete(id: $id)
|
||||
@check(expr: "this != null", message: "Movie not found")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authorization Patterns
|
||||
|
||||
### User-Owned Resources
|
||||
|
||||
```graphql
|
||||
# Create with owner
|
||||
mutation CreatePost($content: String!) @auth(level: USER) {
|
||||
post_insert(data: {
|
||||
authorUid_expr: "auth.uid",
|
||||
content: $content
|
||||
})
|
||||
}
|
||||
|
||||
# Read own data only
|
||||
query MyPosts @auth(level: USER) {
|
||||
posts(where: { authorUid: { eq_expr: "auth.uid" }}) {
|
||||
id content
|
||||
}
|
||||
}
|
||||
|
||||
# Update own data only
|
||||
mutation UpdatePost($id: UUID!, $content: String!) @auth(level: USER) {
|
||||
post_update(
|
||||
first: { where: { id: { eq: $id }, authorUid: { eq_expr: "auth.uid" }}},
|
||||
data: { content: $content }
|
||||
)
|
||||
}
|
||||
|
||||
# Delete own data only
|
||||
mutation DeletePost($id: UUID!) @auth(level: USER) {
|
||||
post_delete(
|
||||
first: { where: { id: { eq: $id }, authorUid: { eq_expr: "auth.uid" }}}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Role-Based Access
|
||||
|
||||
```graphql
|
||||
# Admin-only query
|
||||
query AllUsers @auth(expr: "auth.token.admin == true") {
|
||||
users { id email name }
|
||||
}
|
||||
|
||||
# Role from database
|
||||
mutation AdminAction($id: UUID!) @auth(level: USER) @transaction {
|
||||
query @redact {
|
||||
user(key: { uid_expr: "auth.uid" }) {
|
||||
role @check(expr: "this == 'admin'", message: "Admin required")
|
||||
}
|
||||
}
|
||||
# ... admin action
|
||||
}
|
||||
```
|
||||
|
||||
### Public Data with Filters
|
||||
|
||||
```graphql
|
||||
query PublicPosts @auth(level: PUBLIC) {
|
||||
posts(where: {
|
||||
visibility: { eq: "public" },
|
||||
publishedAt: { lt_expr: "request.time" }
|
||||
}) {
|
||||
id title content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tiered Access (Pro Content)
|
||||
|
||||
```graphql
|
||||
query ProContent @auth(expr: "auth.token.plan == 'pro'") {
|
||||
posts(where: { visibility: { in: ["public", "pro"] }}) {
|
||||
id title content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ Don't Pass User ID as Variable
|
||||
|
||||
```graphql
|
||||
# BAD - any user can pass any userId
|
||||
query GetUserPosts($userId: String!) @auth(level: USER) {
|
||||
posts(where: { authorUid: { eq: $userId }}) { ... }
|
||||
}
|
||||
|
||||
# GOOD - use auth.uid
|
||||
query GetMyPosts @auth(level: USER) {
|
||||
posts(where: { authorUid: { eq_expr: "auth.uid" }}) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Don't Use USER Without Filters
|
||||
|
||||
```graphql
|
||||
# BAD - any authenticated user sees all documents
|
||||
query AllDocs @auth(level: USER) {
|
||||
documents { id title content }
|
||||
}
|
||||
|
||||
# GOOD - filter to user's documents
|
||||
query MyDocs @auth(level: USER) {
|
||||
documents(where: { ownerId: { eq_expr: "auth.uid" }}) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Don't Trust Unverified Email
|
||||
|
||||
```graphql
|
||||
# BAD - email not verified
|
||||
@auth(expr: "auth.token.email.endsWith('@company.com')")
|
||||
|
||||
# GOOD - verify email first
|
||||
@auth(expr: "auth.token.email_verified && auth.token.email.endsWith('@company.com')")
|
||||
```
|
||||
|
||||
### ❌ Don't Use PUBLIC/USER for Prototyping
|
||||
|
||||
During development, set operations to `NO_ACCESS` until you implement proper authorization. Use emulator and VS Code extension for testing.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Templates
|
||||
|
||||
Ready-to-use templates for common Firebase Data Connect patterns.
|
||||
|
||||
---
|
||||
|
||||
## Basic CRUD Schema
|
||||
|
||||
```graphql
|
||||
# schema.gql
|
||||
type Item @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
name: String!
|
||||
description: String
|
||||
createdAt: Timestamp! @default(expr: "request.time")
|
||||
updatedAt: Timestamp! @default(expr: "request.time")
|
||||
}
|
||||
```
|
||||
|
||||
```graphql
|
||||
# queries.gql
|
||||
query ListItems @auth(level: PUBLIC) {
|
||||
items(orderBy: [{ createdAt: DESC }]) {
|
||||
id name description createdAt
|
||||
}
|
||||
}
|
||||
|
||||
query GetItem($id: UUID!) @auth(level: PUBLIC) {
|
||||
item(id: $id) { id name description createdAt updatedAt }
|
||||
}
|
||||
```
|
||||
|
||||
```graphql
|
||||
# mutations.gql
|
||||
mutation CreateItem($name: String!, $description: String) @auth(level: USER) {
|
||||
item_insert(data: { name: $name, description: $description })
|
||||
}
|
||||
|
||||
mutation UpdateItem($id: UUID!, $name: String, $description: String) @auth(level: USER) {
|
||||
item_update(id: $id, data: {
|
||||
name: $name,
|
||||
description: $description,
|
||||
updatedAt_expr: "request.time"
|
||||
})
|
||||
}
|
||||
|
||||
mutation DeleteItem($id: UUID!) @auth(level: USER) {
|
||||
item_delete(id: $id)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User-Owned Resources
|
||||
|
||||
```graphql
|
||||
# schema.gql
|
||||
type User @table(key: "uid") {
|
||||
uid: String! @default(expr: "auth.uid")
|
||||
email: String! @unique
|
||||
displayName: String
|
||||
}
|
||||
|
||||
type Note @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
owner: User!
|
||||
title: String!
|
||||
content: String
|
||||
createdAt: Timestamp! @default(expr: "request.time")
|
||||
}
|
||||
```
|
||||
|
||||
```graphql
|
||||
# queries.gql
|
||||
query MyNotes @auth(level: USER) {
|
||||
notes(
|
||||
where: { owner: { uid: { eq_expr: "auth.uid" }}},
|
||||
orderBy: [{ createdAt: DESC }]
|
||||
) { id title content createdAt }
|
||||
}
|
||||
|
||||
query GetMyNote($id: UUID!) @auth(level: USER) {
|
||||
note(
|
||||
first: { where: {
|
||||
id: { eq: $id },
|
||||
owner: { uid: { eq_expr: "auth.uid" }}
|
||||
}}
|
||||
) { id title content }
|
||||
}
|
||||
```
|
||||
|
||||
```graphql
|
||||
# mutations.gql
|
||||
mutation CreateNote($title: String!, $content: String) @auth(level: USER) {
|
||||
note_insert(data: {
|
||||
owner: { uid_expr: "auth.uid" },
|
||||
title: $title,
|
||||
content: $content
|
||||
})
|
||||
}
|
||||
|
||||
mutation UpdateNote($id: UUID!, $title: String, $content: String) @auth(level: USER) {
|
||||
note_update(
|
||||
first: { where: { id: { eq: $id }, owner: { uid: { eq_expr: "auth.uid" }}}},
|
||||
data: { title: $title, content: $content }
|
||||
)
|
||||
}
|
||||
|
||||
mutation DeleteNote($id: UUID!) @auth(level: USER) {
|
||||
note_delete(
|
||||
first: { where: { id: { eq: $id }, owner: { uid: { eq_expr: "auth.uid" }}}}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Many-to-Many Relationship
|
||||
|
||||
```graphql
|
||||
# schema.gql
|
||||
type Tag @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
name: String! @unique
|
||||
}
|
||||
|
||||
type Article @table {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
title: String!
|
||||
content: String!
|
||||
}
|
||||
|
||||
type ArticleTag @table(key: ["article", "tag"]) {
|
||||
article: Article!
|
||||
tag: Tag!
|
||||
}
|
||||
```
|
||||
|
||||
```graphql
|
||||
# queries.gql
|
||||
query ArticlesByTag($tagName: String!) @auth(level: PUBLIC) {
|
||||
articles(where: {
|
||||
articleTags_on_article: { tag: { name: { eq: $tagName }}}
|
||||
}) {
|
||||
id title
|
||||
tags: tags_via_ArticleTag { name }
|
||||
}
|
||||
}
|
||||
|
||||
query ArticleWithTags($id: UUID!) @auth(level: PUBLIC) {
|
||||
article(id: $id) {
|
||||
id title content
|
||||
tags: tags_via_ArticleTag { id name }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```graphql
|
||||
# mutations.gql
|
||||
mutation AddTagToArticle($articleId: UUID!, $tagId: UUID!) @auth(level: USER) {
|
||||
articleTag_insert(data: {
|
||||
article: { id: $articleId },
|
||||
tag: { id: $tagId }
|
||||
})
|
||||
}
|
||||
|
||||
mutation RemoveTagFromArticle($articleId: UUID!, $tagId: UUID!) @auth(level: USER) {
|
||||
articleTag_delete(key: { articleId: $articleId, tagId: $tagId })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## dataconnect.yaml Template
|
||||
|
||||
```yaml
|
||||
specVersion: "v1"
|
||||
serviceId: "my-service"
|
||||
location: "us-central1"
|
||||
schema:
|
||||
source: "./schema"
|
||||
datasource:
|
||||
postgresql:
|
||||
database: "fdcdb"
|
||||
cloudSql:
|
||||
instanceId: "my-instance"
|
||||
connectorDirs: ["./connector"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## connector.yaml Template
|
||||
|
||||
```yaml
|
||||
connectorId: "default"
|
||||
generate:
|
||||
javascriptSdk:
|
||||
outputDir: "../web/src/lib/dataconnect"
|
||||
package: "@myapp/dataconnect"
|
||||
kotlinSdk:
|
||||
outputDir: "../android/app/src/main/kotlin/com/myapp/dataconnect"
|
||||
package: "com.myapp.dataconnect"
|
||||
swiftSdk:
|
||||
outputDir: "../ios/MyApp/DataConnect"
|
||||
dartSdk:
|
||||
outputDir: "../flutter/lib/dataconnect"
|
||||
package: myapp_dataconnect
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Firebase Init Commands
|
||||
|
||||
```bash
|
||||
# Initialize Data Connect in project
|
||||
npx -y firebase-tools@latest init dataconnect
|
||||
|
||||
# Initialize with specific project
|
||||
npx -y firebase-tools@latest use <project-id>
|
||||
npx -y firebase-tools@latest init dataconnect
|
||||
|
||||
# Start emulator for development
|
||||
npx -y firebase-tools@latest emulators:start --only dataconnect
|
||||
|
||||
# Generate SDKs
|
||||
npx -y firebase-tools@latest dataconnect:sdk:generate
|
||||
|
||||
# Deploy to production
|
||||
npx -y firebase-tools@latest deploy --only dataconnect
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SDK Initialization (Web)
|
||||
|
||||
```typescript
|
||||
// lib/firebase.ts
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import { getDataConnect, connectDataConnectEmulator } from 'firebase/data-connect';
|
||||
import { connectorConfig } from '@myapp/dataconnect';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "...",
|
||||
authDomain: "...",
|
||||
projectId: "...",
|
||||
};
|
||||
|
||||
export const app = initializeApp(firebaseConfig);
|
||||
export const auth = getAuth(app);
|
||||
export const dataConnect = getDataConnect(app, connectorConfig);
|
||||
|
||||
// Connect to emulator in development
|
||||
if (import.meta.env.DEV) {
|
||||
connectDataConnectEmulator(dataConnect, 'localhost', 9399);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Example usage
|
||||
import { listItems, createItem } from '@myapp/dataconnect';
|
||||
|
||||
// List items
|
||||
const { data } = await listItems();
|
||||
console.log(data.items);
|
||||
|
||||
// Create item (requires auth)
|
||||
await createItem({ name: 'New Item', description: 'Description' });
|
||||
```
|
||||
Reference in New Issue
Block a user