Bknd实体关系定义Skill bknd-define-relationship

此技能用于在后端框架Bknd中定义数据库实体之间的关系,涵盖一对一、一对多、多对多和自引用关系。提供UI和代码两种方法,支持外键配置、连接表管理、关系查询和常见模式实现,适用于数据库设计和后端开发,关键词包括Bknd、实体关系、数据库外键、后端框架、关系型数据库。

后端开发 0 次安装 0 次浏览 更新于 3/8/2026

名称:bknd-定义-关系 描述:在定义Bknd实体间关系时使用。涵盖多对一、一对一、多对多、自引用关系、连接表、选项如mappedBy和inversedBy,以及UI与代码方法。

定义实体关系

在Bknd中创建实体间关系(外键、引用、关联)。

前提条件

  • 至少存在两个实体(参见bknd-创建-实体
  • 对于代码模式:访问您的架构文件

关系类型

类型 使用场景 示例
多对一 子实体属于一个父实体 文章 → 用户(作者)
一对一 唯一的1:1配对 用户 → 个人资料
多对多 双方都有多个实例 文章 ↔ 标签
自引用 实体引用自身 类别 → 父类别

何时使用UI与代码

使用UI模式时

  • 快速原型设计
  • 视觉学习者
  • 非开发人员设置关系

使用代码模式时

  • 需要版本控制
  • 可复现的架构
  • 自定义选项(mappedBy、connectionTable)
  • 团队协作

UI方法

步骤1:访问数据部分

  1. 启动服务器:npx bknd run
  2. 打开 http://localhost:1337
  3. 导航到 数据 部分

步骤2:添加关系字段

  1. 点击 实体(例如,posts
  2. 点击 + 添加字段
  3. 选择 关系 字段类型
  4. 选择目标实体(例如,users
  5. 选择关系类型:
    • 多对一:多个文章可以属于一个用户
    • 一对一:一个文章恰好有一个用户
    • 多对多:文章可以有多个标签,标签可以有多个文章

步骤3:配置选项

  • 字段名称:外键的名称(例如,author 创建 author_id
  • 必需:切换关系是否强制

步骤4:保存和同步

  1. 点击 保存字段
  2. 点击 同步数据库 应用更改

代码方法

关系定义在 em() 的第二个参数中:

const schema = em(
  {
    // 实体定义(第一个参数)
  },
  ({ relation, index }, entities) => {
    // 关系定义(第二个参数)
  }
);

多对一

子实体属于一个父实体。最常见的关系类型。

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);
  }
);

自动生成: users_id 外键列在 posts 表上

自定义字段名称使用 mappedBy

({ relation }, { users, posts }) => {
  relation(posts).manyToOne(users, {
    mappedBy: "author",  // 创建 author_id 而不是 users_id
  });
}

一对一

唯一的1:1关系。每个子实体恰好属于一个父实体。

const schema = em(
  {
    users: entity("users", { email: text().required() }),
    profiles: entity("profiles", { bio: text() }),
  },
  ({ relation }, { users, profiles }) => {
    relation(profiles).oneToOne(users);
  }
);

注意: 一对一关系不能使用 $set 操作符(保持唯一性)。

多对多

双方实体都可以有多个对方实例。自动创建连接表。

const schema = em(
  {
    posts: entity("posts", { title: text().required() }),
    tags: entity("tags", { name: text().required() }),
  },
  ({ relation }, { posts, tags }) => {
    relation(posts).manyToMany(tags);
  }
);

自动生成: posts_tags 连接表,包含 posts_idtags_id

自定义连接表名称:

({ relation }, { posts, tags }) => {
  relation(posts).manyToMany(tags, {
    connectionTable: "post_tags",  // 自定义连接表名称
  });
}

连接表上的额外字段:

({ relation }, { users, courses }) => {
  relation(users).manyToMany(courses, {
    connectionTable: "enrollments",
  }, {
    // 连接表上的额外字段
    enrolled_at: date(),
    completed: boolean(),
    grade: number(),
  });
}

自引用

实体引用自身。常用于层次结构(类别、评论、组织图)。

const schema = em(
  {
    categories: entity("categories", { name: text().required() }),
  },
  ({ relation }, { categories }) => {
    relation(categories).manyToOne(categories, {
      mappedBy: "parent",      // FK 字段:parent_id
      inversedBy: "children",  // 反向导航
    });
  }
);

用法:

  • category.parent_id → 指向父类别
  • 查询子项:api.data.readMany("categories", { where: { parent_id: 5 } })

替代方案:直接外键

代替 relation(),使用数字字段上的 .references()

const schema = em({
  users: entity("users", { email: text().required() }),
  posts: entity("posts", {
    title: text().required(),
    author_id: number().references("users.id"),
  }),
});

区别: .references() 更简单但不创建反向导航或不支持多对多。

关系选项

ManyToOne / OneToOne 选项

选项 类型 默认值 描述
mappedBy 字符串 目标实体名称 FK 字段名称(例如,authorauthor_id
inversedBy 字符串 源实体名称 反向导航名称
required 布尔值 false 关系是强制的

ManyToMany 选项

选项 类型 默认值 描述
connectionTable 字符串 {source}_{target} 连接表名称

查询关系

加载相关数据(with)

const api = app.getApi();

// 加载文章及其作者
const posts = await api.data.readMany("posts", {
  with: {
    users: { select: ["email", "name"] },
  },
});
// 结果:[{ id: 1, title: "...", users: { email: "...", name: "..." } }]

通过关系过滤

// 特定作者的文章
const posts = await api.data.readMany("posts", {
  where: { author_id: 5 },
});

// 使用连接进行复杂过滤
const posts = await api.data.readMany("posts", {
  join: {
    users: { where: { email: "john@example.com" } },
  },
});

多对多操作

// 将标签附加到文章
await api.data.updateOne("posts", 1, {
  tags: { $attach: [1, 2, 3] },  // 标签ID
});

// 分离标签
await api.data.updateOne("posts", 1, {
  tags: { $detach: [2] },
});

// 替换所有标签
await api.data.updateOne("posts", 1, {
  tags: { $set: [4, 5] },
});

多对一操作

// 在文章上设置作者
await api.data.updateOne("posts", 1, {
  users: { $set: 5 },  // 用户ID
});

常见模式

博客与作者和标签

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 }) => {
    // 文章有一个作者
    relation(posts).manyToOne(users, { mappedBy: "author" });

    // 文章有多个标签
    relation(posts).manyToMany(tags);
  }
);

电商订单

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 }) => {
    // 订单属于客户
    relation(orders).manyToOne(customers);

    // 订单有多个产品(带数量)
    relation(orders).manyToMany(products, {
      connectionTable: "order_items",
    }, {
      quantity: number().required(),
      unit_price: number().required(),
    });
  }
);

嵌套类别

const schema = em(
  {
    categories: entity("categories", {
      name: text().required(),
      slug: text().required().unique(),
    }),
  },
  ({ relation }, { categories }) => {
    relation(categories).manyToOne(categories, {
      mappedBy: "parent",
      inversedBy: "children",
    });
  }
);

// 用法:获取类别5的所有子项
const children = await api.data.readMany("categories", {
  where: { parent_id: 5 },
});

常见陷阱

实体未找到

错误: Entity "user" not found

修复: 实体名称通常为复数。使用 users 而不是 user

// 错误
relation(posts).manyToOne(user);

// 正确
relation(posts).manyToOne(users);

循环引用错误

错误: Circular dependency detected

修复: 对于自引用,使用适当的选项:

// 正确的自引用
relation(categories).manyToOne(categories, {
  mappedBy: "parent",
  inversedBy: "children",
});

外键命名冲突

错误: Field "users_id" already exists

修复: 使用 mappedBy 指定不同的字段名称:

// 如果已有 users_id,使用不同的名称
relation(posts).manyToOne(users, { mappedBy: "author" });  // 创建 author_id

一对一关系上的多对多 $set

错误: Cannot use $set on one-to-one relation

修复: 一对一关系通过不同方式保持唯一性。改用 $create

// 对于一对一
await api.data.updateOne("users", 1, {
  profiles: { $create: { bio: "Hello" } },
});

在解构中缺少实体

错误: Cannot read property 'manyToOne' of undefined

修复: 确保实体从第二个回调参数中解构出来:

// 错误 - 解构中缺少 users
({ relation }, { posts }) => {
  relation(posts).manyToOne(users);  // users 未定义
}

// 正确
({ relation }, { users, posts }) => {
  relation(posts).manyToOne(users);
}

关系更改未应用

问题: 添加了关系但没有看到FK列。

修复:

  1. 重启服务器(架构在启动时同步)
  2. 验证关系在第二个 em() 参数中
  3. 检查语法错误

验证

检查外键是否创建

npx bknd debug paths
# 在实体输出中查找FK字段

在代码中测试关系

const api = app.getApi();

// 创建父实体
const user = await api.data.createOne("users", { email: "test@example.com" });

// 创建带关系的子实体
const post = await api.data.createOne("posts", {
  title: "测试文章",
  author_id: user.data.id,
});

// 加载带关系
const loaded = await api.data.readOne("posts", post.data.id, {
  with: { users: true },
});
console.log(loaded.data.users);  // { id: 1, email: "test@example.com" }

做与不做

做:

  • 使用复数实体名称(users, posts
  • 使用 mappedBy 获得语义化字段名称(author 而不是 users
  • 在第二个 em() 参数中定义关系
  • 使用 .references() 用于无需导航的简单FK

不做:

  • 在关系中使用单数实体名称
  • 在使用 relation() 时创建手动FK字段(它会自动创建)
  • 在一对一关系上使用 $set
  • 忘记在回调中解构实体

相关技能

  • bknd-创建-实体 - 在定义关系前创建实体
  • bknd-添加-字段 - 添加字段包括用于简单FK的 .references()
  • bknd-CRUD-读取 - 使用 withjoin 查询相关数据
  • bknd-CRUD-更新 - 使用 $attach, $detach, $set 进行关系更新
  • bknd-查询-过滤 - 关系上的高级过滤