Skip to main content

Overview

Relationships define how tables are connected in your Zero schema. They enable type-safe querying of related data and support both one-to-one, one-to-many, and many-to-many relationships.

Functions

relationships()

Defines relationships for a table.
function relationships<TSource extends TableSchema, TRelationships extends Record<string, Relationship>>(
  table: TableBuilderWithColumns<TSource>,
  cb: (connects: {
    many: ManyConnector<TSource>;
    one: OneConnector<TSource>;
  }) => TRelationships
): { name: string; relationships: TRelationships }
Parameters:
  • table - The source table builder
  • cb - Callback function that receives one and many connectors and returns relationship definitions
Returns: An object with the table name and its relationships Example:
const issueRelationships = relationships(issue, ({ one, many }) => ({
  project: one({
    sourceField: ['projectID'],
    destField: ['id'],
    destSchema: project,
  }),
  comments: many({
    sourceField: ['id'],
    destField: ['issueID'],
    destSchema: comment,
  }),
}));

Relationship Types

one()

Defines a one-to-one or many-to-one relationship.
one<TDest extends TableSchema>(arg: {
  sourceField: readonly (keyof TSource['columns'] & string)[];
  destField: readonly (keyof TDest['columns'] & string)[];
  destSchema: TableBuilderWithColumns<TDest>;
}): Relationship
Parameters:
  • sourceField - Array of column names in the source table
  • destField - Array of column names in the destination table
  • destSchema - The destination table builder
Returns: A one-to-one or many-to-one relationship definition Example:
// Issue belongs to one project
const issueRelationships = relationships(issue, ({ one }) => ({
  project: one({
    sourceField: ['projectID'],
    destField: ['id'],
    destSchema: project,
  }),
  creator: one({
    sourceField: ['creatorID'],
    destField: ['id'],
    destSchema: user,
  }),
}));

many()

Defines a one-to-many relationship.
many<TDest extends TableSchema>(arg: {
  sourceField: readonly (keyof TSource['columns'] & string)[];
  destField: readonly (keyof TDest['columns'] & string)[];
  destSchema: TableBuilderWithColumns<TDest>;
}): Relationship
Parameters:
  • sourceField - Array of column names in the source table
  • destField - Array of column names in the destination table
  • destSchema - The destination table builder
Returns: A one-to-many relationship definition Example:
// User has many created issues
const userRelationships = relationships(user, ({ many }) => ({
  createdIssues: many({
    sourceField: ['id'],
    destField: ['creatorID'],
    destSchema: issue,
  }),
  assignedIssues: many({
    sourceField: ['id'],
    destField: ['assigneeID'],
    destSchema: issue,
  }),
}));

// Project has many issues
const projectRelationships = relationships(project, ({ many }) => ({
  issues: many({
    sourceField: ['id'],
    destField: ['projectID'],
    destSchema: issue,
  }),
}));

Multi-Hop Relationships

Relationships can span multiple tables by chaining hops.

Two-Hop many() (Many-to-Many)

Defines a many-to-many relationship through a junction table.
many<TJunction extends TableSchema, TDest extends TableSchema>(
  firstHop: {
    sourceField: readonly string[];
    destField: readonly string[];
    destSchema: TableBuilderWithColumns<TJunction>;
  },
  secondHop: {
    sourceField: readonly string[];
    destField: readonly string[];
    destSchema: TableBuilderWithColumns<TDest>;
  }
): Relationship
Example:
// Issues have many labels through issueLabel junction table
const issueRelationships = relationships(issue, ({ many }) => ({
  labels: many(
    {
      sourceField: ['id'],
      destField: ['issueID'],
      destSchema: issueLabel,  // Junction table
    },
    {
      sourceField: ['labelID'],
      destField: ['id'],
      destSchema: label,  // Final destination
    }
  ),
}));

Two-Hop one()

Defines a one-to-one relationship through an intermediate table.
one<TIntermediate extends TableSchema, TDest extends TableSchema>(
  firstHop: {
    sourceField: readonly string[];
    destField: readonly string[];
    destSchema: TableBuilderWithColumns<TIntermediate>;
  },
  secondHop: {
    sourceField: readonly string[];
    destField: readonly string[];
    destSchema: TableBuilderWithColumns<TDest>;
  }
): Relationship
Example:
// Comment -> Issue -> Project relationship
const commentRelationships = relationships(comment, ({ one }) => ({
  project: one(
    {
      sourceField: ['issueID'],
      destField: ['id'],
      destSchema: issue,
    },
    {
      sourceField: ['projectID'],
      destField: ['id'],
      destSchema: project,
    }
  ),
}));

Complete Example

Here’s a complete example showing various relationship patterns from the zbugs app:
import { relationships } from '@rocicorp/zero';

// User relationships
const userRelationships = relationships(user, ({ many }) => ({
  createdIssues: many({
    sourceField: ['id'],
    destField: ['creatorID'],
    destSchema: issue,
  }),
  assignedIssues: many({
    sourceField: ['id'],
    destField: ['assigneeID'],
    destSchema: issue,
  }),
}));

// Project relationships
const projectRelationships = relationships(project, ({ many }) => ({
  issues: many({
    sourceField: ['id'],
    destField: ['projectID'],
    destSchema: issue,
  }),
  labels: many({
    sourceField: ['id'],
    destField: ['projectID'],
    destSchema: label,
  }),
}));

// Issue relationships (complex example)
const issueRelationships = relationships(issue, ({ many, one }) => ({
  // One-to-one relationships
  project: one({
    sourceField: ['projectID'],
    destField: ['id'],
    destSchema: project,
  }),
  creator: one({
    sourceField: ['creatorID'],
    destField: ['id'],
    destSchema: user,
  }),
  assignee: one({
    sourceField: ['assigneeID'],
    destField: ['id'],
    destSchema: user,
  }),
  
  // One-to-many relationships
  comments: many({
    sourceField: ['id'],
    destField: ['issueID'],
    destSchema: comment,
  }),
  issueLabels: many({
    sourceField: ['id'],
    destField: ['issueID'],
    destSchema: issueLabel,
  }),
  
  // Many-to-many through junction table
  labels: many(
    {
      sourceField: ['id'],
      destField: ['issueID'],
      destSchema: issueLabel,
    },
    {
      sourceField: ['labelID'],
      destField: ['id'],
      destSchema: label,
    }
  ),
}));

// Comment relationships
const commentRelationships = relationships(comment, ({ one, many }) => ({
  creator: one({
    sourceField: ['creatorID'],
    destField: ['id'],
    destSchema: user,
  }),
  issue: one({
    sourceField: ['issueID'],
    destField: ['id'],
    destSchema: issue,
  }),
  emoji: many({
    sourceField: ['id'],
    destField: ['subjectID'],
    destSchema: emoji,
  }),
}));

// Junction table relationships
const issueLabelRelationships = relationships(issueLabel, ({ one }) => ({
  issue: one({
    sourceField: ['issueID'],
    destField: ['id'],
    destSchema: issue,
  }),
  label: one({
    sourceField: ['labelID'],
    destField: ['id'],
    destSchema: label,
  }),
}));

Composite Foreign Keys

Relationships support composite keys by specifying multiple columns:
const viewStateRelationships = relationships(viewState, ({ one }) => ({
  user: one({
    sourceField: ['userID', 'tenantID'],
    destField: ['id', 'tenantID'],
    destSchema: user,
  }),
}));

Querying Relationships

Once defined, relationships can be used in queries:
// Query with relationships
const issuesWithDetails = zero.query.issue
  .related('project', (p) => p)
  .related('creator', (u) => ({ name: u.name, avatar: u.avatar }))
  .related('comments', (c) => c.related('creator', (u) => ({ name: u.name })))
  .where('open', true);

Relationship Validation

Zero validates relationships at schema creation time:
  • Destination table exists: The destSchema table must be defined in the schema
  • Source fields exist: All sourceField columns must exist in the source table
  • Destination fields exist: All destField columns must exist in the destination table
  • No name conflicts: Relationship names cannot conflict with column names

Best Practices

  1. Name relationships semantically: Use names that describe the relationship from the source table’s perspective
  2. Define bidirectional relationships: Define relationships on both sides when querying in both directions
  3. Use junction tables for many-to-many: Always use a dedicated junction table with its own relationships
  4. Keep field arrays consistent: Match the order and count of fields in sourceField and destField
  5. Document multi-hop relationships: Add comments explaining complex relationship chains

Build docs developers (and LLMs) love