Use when defining relationships between Bknd entities. Covers many-to-one, one-to-one, many-to-many, self-referencing relationships, junction tables, options like mappedBy and inversedBy, and UI vs code approaches.
Scanned 2/12/2026
Install to Claude Code
npx -y skills add cameronapak/freedom-stack-v3 --skill bknd-define-relationship --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Bknd Define Relationship?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/cameronapak-bknd-define-relationship)More formats (shields.io, HTML) on the badges page.
---
name: bknd-define-relationship
description: Use when defining relationships between Bknd entities. Covers many-to-one, one-to-one, many-to-many, self-referencing relationships, junction tables, options like mappedBy and inversedBy, and UI vs code approaches.
---
# Define Entity Relationships
Create relationships between entities in Bknd (foreign keys, references, associations).
## Prerequisites
- At least two entities exist (see `bknd-create-entity`)
- For code mode: Access to your schema file
## Relationship Types
| Type | Use Case | Example |
|------|----------|---------|
| Many-to-One | Child belongs to one parent | Posts → User (author) |
| One-to-One | Exclusive 1:1 pairing | User → Profile |
| Many-to-Many | Both sides have multiple | Posts ↔ Tags |
| Self-Referencing | Entity references itself | Categories → Parent Category |
## When to Use UI vs Code
### Use UI Mode When
- Quick prototyping
- Visual learners
- Non-developers setting up relationships
### Use Code Mode When
- Version control needed
- Reproducible schema
- Custom options (mappedBy, connectionTable)
- Team collaboration
## UI Approach
### Step 1: Access Data Section
1. Start server: `npx bknd run`
2. Open `http://localhost:1337`
3. Navigate to **Data** section
### Step 2: Add Relation Field
1. Click on the **child** entity (e.g., `posts`)
2. Click **+ Add Field**
3. Select **Relation** field type
4. Choose the target entity (e.g., `users`)
5. Select relationship type:
- **Many-to-One**: Multiple posts can belong to one user
- **One-to-One**: One post has exactly one user
- **Many-to-Many**: Posts can have many tags, tags can have many posts
### Step 3: Configure Options
- **Field Name**: Name for the foreign key (e.g., `author` creates `author_id`)
- **Required**: Toggle if relationship is mandatory
### Step 4: Save and Sync
1. Click **Save Field**
2. Click **Sync Database** to apply changes
## Code Approach
Relationships are defined in the second argument to `em()`:
```typescript
const schema = em(
{
// Entity definitions (first argument)
},
({ relation, index }, entities) => {
// Relationship definitions (second argument)
}
);
```
### Many-to-One
Child belongs to one parent. Most common relationship type.
```typescript
import { em, entity, text } from "bknd";
const schema = em(
{
users: entity("users", { email: text().required() }),
posts: entity("posts", { title: text().required() }),
},
({ relation }, { users, posts }) => {
relation(posts).manyToOne(users);
}
);
```
**Auto-generated:** `users_id` foreign key column on `posts` table
**Custom field name with `mappedBy`:**
```typescript
({ relation }, { users, posts }) => {
relation(posts).manyToOne(users, {
mappedBy: "author", // Creates author_id instead of users_id
});
}
```
### One-to-One
Exclusive 1:1 relationship. Each child belongs to exactly one parent.
```typescript
const schema = em(
{
users: entity("users", { email: text().required() }),
profiles: entity("profiles", { bio: text() }),
},
({ relation }, { users, profiles }) => {
relation(profiles).oneToOne(users);
}
);
```
**Note:** One-to-one relationships cannot use `$set` operator (maintains exclusivity).
### Many-to-Many
Both entities can have multiple of the other. Junction table created automatically.
```typescript
const schema = em(
{
posts: entity("posts", { title: text().required() }),
tags: entity("tags", { name: text().required() }),
},
({ relation }, { posts, tags }) => {
relation(posts).manyToMany(tags);
}
);
```
**Auto-generated:** `posts_tags` junction table with `posts_id` and `tags_id` columns
**Custom junction table name:**
```typescript
({ relation }, { posts, tags }) => {
relation(posts).manyToMany(tags, {
connectionTable: "post_tags", // Custom junction table name
});
}
```
**Extra fields on junction table:**
```typescript
({ relation }, { users, courses }) => {
relation(users).manyToMany(courses, {
connectionTable: "enrollments",
}, {
// Extra fields on junction table
enrolled_at: date(),
completed: boolean(),
grade: number(),
});
}
```
### Self-Referencing
Entity references itself. Common for hierarchies (categories, comments, org charts).
```typescript
const schema = em(
{
categories: entity("categories", { name: text().required() }),
},
({ relation }, { categories }) => {
relation(categories).manyToOne(categories, {
mappedBy: "parent", // FK field: parent_id
inversedBy: "children", // Reverse navigation
});
}
);
```
**Usage:**
- `category.parent_id` → Points to parent category
- Query children: `api.data.readMany("categories", { where: { parent_id: 5 } })`
## Alternative: Direct Foreign Key
Instead of `relation()`, use `.references()` on a number field:
```typescript
const schema = em({
users: entity("users", { email: text().required() }),
posts: entity("posts", {
title: text().required(),
author_id: number().references("users.id"),
}),
});
```
**Difference:** `.references()` is simpler but doesn't create inverse navigation or support many-to-many.
## Relation Options
### ManyToOne / OneToOne Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mappedBy` | string | Target entity name | FK field name (e.g., `author` → `author_id`) |
| `inversedBy` | string | Source entity name | Reverse navigation name |
| `required` | boolean | false | Relationship is mandatory |
### ManyToMany Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `connectionTable` | string | `{source}_{target}` | Junction table name |
## Querying Relations
### Load Related Data (with)
```typescript
const api = app.getApi();
// Load posts with their author
const posts = await api.data.readMany("posts", {
with: {
users: { select: ["email", "name"] },
},
});
// Result: [{ id: 1, title: "...", users: { email: "...", name: "..." } }]
```
### Filter by Relation
```typescript
// Posts by specific author
const posts = await api.data.readMany("posts", {
where: { author_id: 5 },
});
// Using join for complex filters
const posts = await api.data.readMany("posts", {
join: {
users: { where: { email: "john@example.com" } },
},
});
```
### Many-to-Many Operations
```typescript
// Attach tags to post
await api.data.updateOne("posts", 1, {
tags: { $attach: [1, 2, 3] }, // Tag IDs
});
// Detach tags
await api.data.updateOne("posts", 1, {
tags: { $detach: [2] },
});
// Replace all tags
await api.data.updateOne("posts", 1, {
tags: { $set: [4, 5] },
});
```
### Many-to-One Operations
```typescript
// Set author on post
await api.data.updateOne("posts", 1, {
users: { $set: 5 }, // User ID
});
```
## Common Patterns
### Blog with Authors and Tags
```typescript
const schema = em(
{
users: entity("users", {
email: text().required().unique(),
name: text(),
}),
posts: entity("posts", {
title: text().required(),
content: text(),
published: boolean(),
}),
tags: entity("tags", {
name: text().required().unique(),
}),
},
({ relation }, { users, posts, tags }) => {
// Post has one author
relation(posts).manyToOne(users, { mappedBy: "author" });
// Posts have many tags
relation(posts).manyToMany(tags);
}
);
```
### E-commerce Orders
```typescript
const schema = em(
{
customers: entity("customers", { email: text().required() }),
orders: entity("orders", { total: number() }),
products: entity("products", { name: text().required(), price: number() }),
},
({ relation }, { customers, orders, products }) => {
// Order belongs to customer
relation(orders).manyToOne(customers);
// Order has many products (with quantity)
relation(orders).manyToMany(products, {
connectionTable: "order_items",
}, {
quantity: number().required(),
unit_price: number().required(),
});
}
);
```
### Nested Categories
```typescript
const schema = em(
{
categories: entity("categories", {
name: text().required(),
slug: text().required().unique(),
}),
},
({ relation }, { categories }) => {
relation(categories).manyToOne(categories, {
mappedBy: "parent",
inversedBy: "children",
});
}
);
// Usage: Get all children of category 5
const children = await api.data.readMany("categories", {
where: { parent_id: 5 },
});
```
## Common Pitfalls
### Entity Not Found
**Error:** `Entity "user" not found`
**Fix:** Entity names are plural by convention. Use `users` not `user`.
```typescript
// Wrong
relation(posts).manyToOne(user);
// Correct
relation(posts).manyToOne(users);
```
### Circular Reference Error
**Error:** `Circular dependency detected`
**Fix:** For self-referencing, use proper options:
```typescript
// Correct self-reference
relation(categories).manyToOne(categories, {
mappedBy: "parent",
inversedBy: "children",
});
```
### Foreign Key Naming Conflict
**Error:** `Field "users_id" already exists`
**Fix:** Use `mappedBy` to specify a different field name:
```typescript
// If you already have users_id, use a different name
relation(posts).manyToOne(users, { mappedBy: "author" }); // Creates author_id
```
### Many-to-Many $set on One-to-One
**Error:** `Cannot use $set on one-to-one relation`
**Fix:** One-to-one maintains exclusivity differently. Use `$create` instead:
```typescript
// For one-to-one
await api.data.updateOne("users", 1, {
profiles: { $create: { bio: "Hello" } },
});
```
### Missing Entity in Destructure
**Error:** `Cannot read property 'manyToOne' of undefined`
**Fix:** Ensure entity is destructured from second callback parameter:
```typescript
// Wrong - missing users in destructure
({ relation }, { posts }) => {
relation(posts).manyToOne(users); // users is undefined
}
// Correct
({ relation }, { users, posts }) => {
relation(posts).manyToOne(users);
}
```
### Relation Changes Not Applying
**Problem:** Added relation but not seeing FK column.
**Fixes:**
1. Restart server (schema syncs on startup)
2. Verify relation is in second `em()` argument
3. Check for syntax errors
## Verification
### Check Foreign Key Created
```bash
npx bknd debug paths
# Look for the FK field in entity output
```
### Test Relation in Code
```typescript
const api = app.getApi();
// Create parent
const user = await api.data.createOne("users", { email: "test@example.com" });
// Create child with relation
const post = await api.data.createOne("posts", {
title: "Test Post",
author_id: user.data.id,
});
// Load with relation
const loaded = await api.data.readOne("posts", post.data.id, {
with: { users: true },
});
console.log(loaded.data.users); // { id: 1, email: "test@example.com" }
```
## DOs and DON'Ts
**DO:**
- Use plural entity names (`users`, `posts`)
- Use `mappedBy` for semantic field names (`author` instead of `users`)
- Define relations in the second `em()` argument
- Use `.references()` for simple FK without navigation
**DON'T:**
- Use singular entity names in relations
- Create manual FK fields when using `relation()` (it creates them automatically)
- Use `$set` on one-to-one relations
- Forget to destructure entities in the callback
## Related Skills
- **bknd-create-entity** - Create entities before defining relationships
- **bknd-add-field** - Add fields including `.references()` for simple FKs
- **bknd-crud-read** - Query related data with `with` and `join`
- **bknd-crud-update** - Use `$attach`, `$detach`, `$set` for relation updates
- **bknd-query-filter** - Advanced filtering on relations
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!