关联 - 将表连接在一起

在你的应用程序中定义不同对象之间的关系应该是一个自然的过程。例如,一篇文章可能有多个评论,并且属于一个作者。作者可能有多篇文章和评论。CakePHP 中的四种关联类型是:hasOne、hasMany、belongsTo 和 belongsToMany。

关系

关联类型

示例

一对一

hasOne

一个用户有一个配置文件。

一对多

hasMany

一个用户可以有多篇文章。

多对一

belongsTo

多篇文章属于一个用户。

多对多

belongsToMany

标签属于多篇文章。

关联是在你的表格对象的 initialize() 方法中定义的。与关联类型匹配的方法允许你在你的应用程序中定义关联。例如,如果我们想在我们的 ArticlesTable 中定义一个 belongsTo 关联

namespace App\Model\Table;

use Cake\ORM\Table;

class ArticlesTable extends Table
{
    public function initialize(array $config): void
    {
        $this->belongsTo('Authors');
    }
}

任何关联设置的最简单形式都接受你想要关联的表格别名。默认情况下,所有关联的细节都将使用 CakePHP 约定。如果你想自定义你的关联的处理方式,你可以使用设置器修改它们

class ArticlesTable extends Table
{
    public function initialize(array $config): void
    {
        $this->belongsTo('Authors', [
                'className' => 'Publishing.Authors'
            ])
            ->setForeignKey('author_id')
            ->setProperty('author');
    }
}

属性名称将是实体对象上的关联实体的属性键,在本例中为

$authorEntity = $articleEntity->author;

你也可以使用数组来自定义你的关联

$this->belongsTo('Authors', [
    'className' => 'Publishing.Authors',
    'foreignKey' => 'author_id',
    'propertyName' => 'author'
]);

但是,数组不提供流式接口所提供的类型提示和自动完成功能。

同一张表可以被多次使用来定义不同类型的关联。例如,考虑你想要将已批准的评论和尚未被审核的评论分开的情况

class ArticlesTable extends Table
{
    public function initialize(array $config): void
    {
        $this->hasMany('Comments')
            ->setFinder('approved');

        $this->hasMany('UnapprovedComments', [
                'className' => 'Comments'
            ])
            ->setFinder('unapproved')
            ->setProperty('unapproved_comments');
    }
}

如你所见,通过指定 className 键,可以将同一张表用作同一张表的不同关联。你甚至可以创建自关联的表来创建父子关系

class CategoriesTable extends Table
{
    public function initialize(array $config): void
    {
        $this->hasMany('SubCategories', [
            'className' => 'Categories',
        ]);

        $this->belongsTo('ParentCategories', [
            'className' => 'Categories',
        ]);
    }
}

你也可以通过对 Table::addAssociations() 进行一次调用来批量设置关联,它接受一个包含以关联类型为索引的表格名称集的数组作为参数

class PostsTable extends Table
{
    public function initialize(array $config): void
    {
       $this->addAssociations([
           'belongsTo' => [
               'Users' => ['className' => 'App\Model\Table\UsersTable'],
           ],
           'hasMany' => ['Comments'],
           'belongsToMany' => ['Tags'],
       ]);
    }
}

每个关联类型都接受多个关联,其中键是别名,值是关联配置数据。如果使用数字键,则值将被视为关联别名。

HasOne 关联

让我们设置一个 Users 表格,它与 Addresses 表格有一个 hasOne 关系。

首先,你的数据库表格需要正确地加键。为了使 hasOne 关系起作用,一个表格必须包含指向另一个表格中记录的外部键。在本例中,Addresses 表格将包含一个名为 ‘user_id’ 的字段。基本模式是

hasOne: 另一个 模型包含外部键。

关系

模式

Users hasOne Addresses

addresses.user_id

Doctors hasOne Mentors

mentors.doctor_id

注意

不必强制遵循 CakePHP 约定,你可以在你的关联定义中覆盖任何 foreignKey 的名称。但是,坚持约定会使你的代码重复性更少,更易于阅读和维护。

创建 UsersTableAddressesTable 类后,可以使用以下代码进行关联

class UsersTable extends Table
{
    public function initialize(array $config): void
    {
        $this->hasOne('Addresses');
    }
}

如果你需要更多控制,可以使用设置器定义你的关联。例如,你可能想要限制关联以仅包含某些记录

class UsersTable extends Table
{
    public function initialize(array $config): void
    {
        $this->hasOne('Addresses')
            ->setName('Addresses')
            ->setFinder('primary')
            ->setDependent(true);
    }
}

如果你想将不同的地址分解成多个关联,你可以执行类似的操作

class UsersTable extends Table
{
    public function initialize(array $config): void
    {
        $this->hasOne('HomeAddresses', [
                'className' => 'Addresses'
            ])
            ->setProperty('home_address')
            ->setConditions(['HomeAddresses.label' => 'Home'])
            ->setDependent(true);

        $this->hasOne('WorkAddresses', [
                'className' => 'Addresses'
            ])
            ->setProperty('work_address')
            ->setConditions(['WorkAddresses.label' => 'Work'])
            ->setDependent(true);
    }
}

注意

如果多个 hasOne 关联共享一个列,则必须使用关联别名限定它。在上面的示例中,’label’ 列使用 ‘HomeAddresses’ 和 ‘WorkAddresses’ 别名限定。

HasOne 关联数组的可能键包括

  • className: 另一个表格的类名。这是在获取表格实例时使用的相同名称。在 ‘Users hasOne Addresses’ 示例中,它应该是 ‘Addresses’。默认值为关联的名称。

  • foreignKey: 另一个表格中外部键列的名称。默认值为当前模型的带下划线的单数名称,后缀为 ‘_id’,例如上面的示例中的 ‘user_id’。

  • bindingKey: 当前表格中用于匹配 foreignKey 的列的名称。默认值为当前表格的主键,例如上面示例中 Users 的 ‘id’。

  • conditions: 一个包含 find() 兼容条件的数组,例如 ['Addresses.primary' => true]

  • joinType: SQL 查询中使用的连接类型。接受的值是 ‘LEFT’ 和 ‘INNER’。你可以使用 ‘INNER’ 仅获取设置了关联的结果。默认值为 ‘LEFT’。

  • dependent: 当 dependent 键设置为 true 且一个实体被删除时,关联的模型记录也会被删除。在本例中,我们将它设置为 true,以便删除用户也会删除她关联的地址。

  • cascadeCallbacks: 当这个键和 dependent 键都为 true 时,级联删除将加载和删除实体,以便正确触发回调。当 false 时,deleteAll() 用于删除关联的数据,并且不会触发任何回调。

  • propertyName: 应该用关联表格中的数据填充到源表格结果中的属性名称。默认情况下,这是关联的带下划线和单数的名称,因此在我们的示例中为 address

  • strategy: 用于从另一个表格加载匹配记录的查询策略。接受的值是 'join''select'。使用 'select' 将生成一个单独的查询,当另一个表格位于不同的数据库中时非常有用。默认值为 'join'

  • finder: 用于加载关联记录的查找器方法。

定义了此关联后,对 Users 表格的查找操作可以包含地址记录(如果存在)

// In a controller or table method.
$query = $users->find('all')->contain(['Addresses'])->all();
foreach ($query as $user) {
    echo $user->address->street;
}

以上将发出类似于以下内容的 SQL

SELECT * FROM users INNER JOIN addresses ON addresses.user_id = users.id;

BelongsTo 关联

现在我们已经可以从 User 表格访问 Address 数据,让我们在 Addresses 表格中定义一个 belongsTo 关联,以便访问相关的 User 数据。belongsTo 关联是 hasOne 和 hasMany 关联的自然补充 - 它允许我们从另一个方向查看相关数据。

在为 belongsTo 关系对数据库表格加键时,请遵循以下约定

belongsTo: 当前 模型包含外部键。

关系

模式

Addresses belongsTo Users

addresses.user_id

Mentors belongsTo Doctors

mentors.doctor_id

提示

如果一个表格包含一个外部键,它就属于另一个表格。

我们可以如下在 Addresses 表格中定义 belongsTo 关联

class AddressesTable extends Table
{
    public function initialize(array $config): void
    {
        $this->belongsTo('Users');
    }
}

我们也可以使用设置器定义更具体的关联

class AddressesTable extends Table
{
    public function initialize(array $config): void
    {
        $this->belongsTo('Users')
            ->setForeignKey('user_id')
            ->setJoinType('INNER');
    }
}

belongsTo 关联数组的可能键包括

  • className: 另一个表格的类名。这是在获取表格实例时使用的相同名称。在 ‘Addresses belongsTo Users’ 示例中,它应该是 ‘Users’。默认值为关联的名称。

  • foreignKey: 当前表格中外部键列的名称。默认值为另一个模型的带下划线的单数名称,后缀为 ‘_id’,例如上面的示例中的 ‘user_id’。

  • bindingKey: 另一个表格中用于匹配 foreignKey 的列的名称。默认值为另一个表格的主键,例如上面示例中 Users 的 ‘id’。

  • conditions: 一个包含 find() 兼容条件或 SQL 字符串的数组,例如 ['Users.active' => true]

  • joinType: SQL 查询中使用的连接类型。接受的值是 ‘LEFT’ 和 ‘INNER’。你可以使用 ‘INNER’ 仅获取设置了关联的结果。默认值为 ‘LEFT’。

  • propertyName: 应该用关联表格中的数据填充到源表格结果中的属性名称。默认情况下,这是关联的带下划线和单数的名称,因此在我们的示例中为 user

  • strategy: 用于从另一个表格加载匹配记录的查询策略。接受的值是 'join''select'。使用 'select' 将生成一个单独的查询,当另一个表格位于不同的数据库中时非常有用。默认值为 'join'

  • finder: 用于加载关联记录的查找器方法。

定义了此关联后,对 Addresses 表格的查找操作可以包含 User 记录(如果存在)

// In a controller or table method.
$query = $addresses->find('all')->contain(['Users'])->all();
foreach ($query as $address) {
    echo $address->user->username;
}

以上将输出类似于以下内容的 SQL

SELECT * FROM addresses LEFT JOIN users ON addresses.user_id = users.id;

HasMany 关联

hasMany 关联的一个例子是“Articles hasMany Comments”。定义这种关联后,我们可以在加载文章时获取该文章的评论。

在为 hasMany 关系创建数据库表时,请遵循以下约定

hasMany: 另一个模型包含外键。

关系

模式

Articles hasMany Comments

Comments.article_id

Products hasMany Options

Options.product_id

Doctors hasMany Patients

Patients.doctor_id

我们可以在 Articles 模型中定义 hasMany 关联,如下所示

class ArticlesTable extends Table
{
    public function initialize(array $config): void
    {
        $this->hasMany('Comments');
    }
}

我们也可以使用设置器定义更具体的关联

class ArticlesTable extends Table
{
    public function initialize(array $config): void
    {
        $this->hasMany('Comments')
            ->setForeignKey('article_id')
            ->setDependent(true);
    }
}

有时您可能想要在关联中配置组合键

// Within ArticlesTable::initialize() call
$this->hasMany('Comments')
    ->setForeignKey([
        'article_id',
        'article_hash',
    ]);

根据上面的例子,我们已经将包含所需组合键的数组传递给 setForeignKey()。默认情况下,bindingKey 会自动定义为 idhash,但假设您需要指定与默认值不同的绑定字段。您可以使用 setBindingKey() 手动设置。

// Within ArticlesTable::initialize() call
$this->hasMany('Comments')
    ->setForeignKey([
        'article_id',
        'article_hash',
    ])
    ->setBindingKey([
        'whatever_id',
        'whatever_hash',
    ]);

与 hasOne 关联一样,foreignKey 在另一个 (Comments) 表中,而 bindingKey 在当前 (Articles) 表中。

hasMany 关联数组中的可能键包括

  • className: 另一个表的类名。这与获取表实例时使用的名称相同。在“Articles hasMany Comments”示例中,它应该是“Comments”。默认值为关联的名称。

  • foreignKey: 另一个表中外键列的名称。默认值为当前模型的带下划线单数名称,后缀为“_id”,例如上面的示例中的“article_id”。

  • bindingKey: 当前表中用于匹配 foreignKey 的列的名称。默认值为当前表的主键,例如上面示例中 Articles 的“id”。

  • conditions: 一个包含 find() 兼容条件或 SQL 字符串的数组,例如 ['Comments.visible' => true]。建议使用 finder 选项。

  • sort: 一个包含 find() 兼容排序子句或 SQL 字符串的数组,例如 ['Comments.created' => 'ASC']

  • dependent: 当 dependent 设置为 true 时,可以进行递归模型删除。在本例中,当其关联的 Article 记录被删除时,Comment 记录将被删除。

  • cascadeCallbacks: 当这个键和 dependent 键都为 true 时,级联删除将加载和删除实体,以便正确触发回调。当 false 时,deleteAll() 用于删除关联的数据,并且不会触发任何回调。

  • propertyName: 应该用关联表中的数据填充到源表结果中的属性名称。默认情况下,这是关联的带下划线复数名称,所以我们示例中的 comments

  • strategy: 定义要使用的查询策略。默认为“select”。另一个有效值为“subquery”,它用等效的子查询替换 IN 列表。

  • saveStrategy: “append” 或“replace”。默认为“append”。当“append”时,当前记录将附加到数据库中的任何记录。当“replace”时,当前集合中不存在的关联记录将被删除。如果外键是可为空列,或者如果 dependent 为 true,记录将成为孤儿。

  • finder: 加载关联记录时要使用的查找器方法。有关更多信息,请参见 使用关联查找器 部分。

一旦这种关联被定义,Articles 表上的查找操作可以包含 Comment 记录(如果存在)。

// In a controller or table method.
$query = $articles->find('all')->contain(['Comments'])->all();
foreach ($query as $article) {
    echo $article->comments[0]->text;
}

以上将输出类似于以下内容的 SQL

SELECT * FROM articles;
SELECT * FROM comments WHERE article_id IN (1, 2, 3, 4, 5);

当使用子查询策略时,将生成类似于以下内容的 SQL

SELECT * FROM articles;
SELECT * FROM comments WHERE article_id IN (SELECT id FROM articles);

您可能希望缓存 hasMany 关联的计数。当您经常需要显示关联记录的数量,但不想加载所有记录只是为了计数时,这很有用。例如,任何给定文章的评论计数通常被缓存,以使生成文章列表更有效。您可以使用 CounterCacheBehavior 来缓存关联记录的计数。

您应该确保您的数据库表不包含与关联属性名称匹配的列。例如,如果您有与关联属性冲突的计数器字段,您必须重命名关联属性或列名。

BelongsToMany 关联

BelongsToMany 关联的一个例子是“Article BelongsToMany Tags”,其中一篇文章的标签与其他文章共享。BelongsToMany 通常被称为“has and belongs to many”,是一种经典的“多对多”关联。

hasMany 和 BelongsToMany 之间的主要区别在于,BelongsToMany 关联中模型之间的链接不是独占的。例如,我们将 Articles 表与 Tags 表连接起来。在文章中使用“funny”作为标签,并不会“用完”这个标签。我也可以在下一篇文章中使用它。

BelongsToMany 关联需要三个数据库表。在上面的示例中,我们需要 articlestagsarticles_tags 的表。articles_tags 表包含将标签和文章链接在一起的数据。连接表以涉及的两个表命名,并以下划线分隔,这是惯例。在最简单的形式中,该表包含 article_idtag_id,以及跨越两列的多列 PRIMARY KEY 索引。

belongsToMany 需要一个单独的连接表,该表包含两个模型名称。

关系

连接表字段

Articles belongsToMany Tags

articles_tags.id, articles_tags.tag_id, articles_tags.article_id

Patients belongsToMany Doctors

doctors_patients.id, doctors_patients.doctor_id, doctors_patients.patient_id.

我们可以在我们的两个模型中定义 belongsToMany 关联,如下所示

// In src/Model/Table/ArticlesTable.php
class ArticlesTable extends Table
{
    public function initialize(array $config): void
    {
        $this->belongsToMany('Tags');
    }
}

// In src/Model/Table/TagsTable.php
class TagsTable extends Table
{
    public function initialize(array $config): void
    {
        $this->belongsToMany('Articles');
    }
}

我们还可以使用配置定义更具体的关联

// In src/Model/Table/TagsTable.php
class TagsTable extends Table
{
    public function initialize(array $config): void
    {
        $this->belongsToMany('Articles', [
            'joinTable' => 'articles_tags',
        ]);
    }
}

belongsToMany 关联数组中可能的键包括

  • className: 另一个表的类名。这与获取表实例时使用的名称相同。在“Articles belongsToMany Tags”示例中,它应该是“Tags”。默认值为关联的名称。

  • joinTable: 此关联中使用的连接表的名称(如果当前表不符合 belongsToMany 连接表的命名约定)。默认情况下,将使用此表名来加载连接表的 Table 实例。

  • foreignKey: 连接表上引用当前模型的外键的名称,或者在组合外键的情况下为列表。这在您需要定义多个 belongsToMany 关系时特别有用。此键的默认值为当前模型的带下划线单数名称,后缀为“_id”。

  • bindingKey: 当前表中将用于匹配 foreignKey 的列的名称。默认为主键。

  • targetForeignKey: 连接模型上引用目标模型的外键的名称,或者在组合外键的情况下为列表。此键的默认值为目标模型的带下划线单数名称,后缀为“_id”。

  • conditions: 一个包含 find() 兼容条件的数组。如果您对关联表有条件,您应该使用“through”模型,并在其上定义必要的 belongsTo 关联。建议使用 finder 选项。

  • sort: 一个包含 find() 兼容排序子句的数组。

  • dependent: 当 dependent 键设置为 false 时,如果实体被删除,连接表的数据将不会被删除。

  • through: 允许您提供要用于连接表的 Table 实例的别名,或实例本身。这使得自定义连接表键成为可能,并允许您自定义枢轴表的行为。

  • cascadeCallbacks: 当此值为 true 时,级联删除将加载和删除实体,以便在连接表记录上正确触发回调。当 false 时,deleteAll() 用于删除关联数据,并且不会触发任何回调。这默认为 false,以帮助减少开销。

  • propertyName: 应该用关联表中的数据填充到源表结果中的属性名称。默认情况下,这是关联的带下划线复数名称,所以我们示例中的 tags

  • strategy: 定义要使用的查询策略。默认为“select”。另一个有效值为“subquery”,它用等效的子查询替换 IN 列表。

  • saveStrategy: “append” 或“replace”。默认为“replace”。指示用于保存关联实体的模式。前者将只在关系的两侧创建新的链接,而后者将执行擦除和替换,以在保存时在传递的实体之间创建链接。

  • finder: 加载关联记录时要使用的查找器方法。有关更多信息,请参见 使用关联查找器 部分。

一旦这种关联被定义,Articles 表上的查找操作可以包含 Tag 记录(如果存在)。

// In a controller or table method.
$query = $articles->find('all')->contain(['Tags'])->all();
foreach ($query as $article) {
    echo $article->tags[0]->text;
}

以上将输出类似于以下内容的 SQL

SELECT * FROM articles;
SELECT * FROM tags
INNER JOIN articles_tags ON (
  tags.id = article_tags.tag_id
  AND article_id IN (1, 2, 3, 4, 5)
);

当使用子查询策略时,将生成类似于以下内容的 SQL

SELECT * FROM articles;
SELECT * FROM tags
INNER JOIN articles_tags ON (
  tags.id = article_tags.tag_id
  AND article_id IN (SELECT id FROM articles)
);

使用“through”选项

如果你打算在连接/透视表中添加额外信息,或者你需要在约定之外使用连接列,你需要定义 through 选项。 through 选项让你完全控制如何创建 belongsToMany 关联。

有时在多对多关联中存储额外数据是可取的。考虑以下情况

Student BelongsToMany Course
Course BelongsToMany Student

一个学生可以选修很多课程,一个课程可以被很多学生选修。这是一个简单的多对多关联。以下表格就足够了

id | student_id | course_id

现在,如果我们想存储学生在课程上的出勤天数和他们的最终成绩,我们需要的表格应该是

id | student_id | course_id | days_attended | grade

实现我们需求的方法是使用一个 **连接模型**,也称为 **hasMany through** 关联。也就是说,关联本身就是一个模型。所以,我们可以创建一个新的模型 CoursesMemberships。看看以下模型

class StudentsTable extends Table
{
    public function initialize(array $config): void
    {
        $this->belongsToMany('Courses', [
            'through' => 'CoursesMemberships',
        ]);
    }
}

class CoursesTable extends Table
{
    public function initialize(array $config): void
    {
        $this->belongsToMany('Students', [
            'through' => 'CoursesMemberships',
        ]);
    }
}

class CoursesMembershipsTable extends Table
{
    public function initialize(array $config): void
    {
        $this->belongsTo('Students');
        $this->belongsTo('Courses');
    }
}

CoursesMemberships 连接表唯一地标识了给定学生在某个课程上的参与情况,以及额外的元信息。

当使用带有 through 模型的 BelongsToMany 关系的查询对象时,将关联目标表的包含和匹配条件添加到你的查询对象中。 through 表可以随后在其他条件中被引用,例如通过在你要过滤的字段前面指定 through 表名来使用 where 条件

$query = $this->find(
        'list',
        valueField: 'studentFirstName', order: 'students.id'
    )
    ->contain(['Courses'])
    ->matching('Courses')
    ->where(['CoursesMemberships.grade' => 'B']);

使用关联查找器

默认情况下,关联将根据外键列加载记录。如果你想为关联定义额外的条件,可以使用 finder。当加载关联时,ORM 将使用你的 自定义查找器 来加载、更新或删除关联的记录。使用查找器可以封装你的查询并使其更可重用。在使用查找器加载通过连接加载的关联数据 (belongsTo/hasOne) 时,有一些限制。只有查询的以下方面将应用于根查询

  • Where 条件。

  • 额外连接。

  • 包含的关联。

其他查询方面,例如选定的列、顺序、分组、having 和其他子语句,将不会应用于根查询。 _不是_ 通过连接加载的关联 (hasMany/belongsToMany) 没有上述限制,也可以使用结果格式化程序或 map/reduce 函数。

关联约定

默认情况下,关联应该使用 CamelCase 样式进行配置和引用。这使得能够以以下方式链接到相关表的属性

$this->MyTableOne->MyTableTwo->find()->...;

实体上的关联属性不使用 CamelCase 约定。相反,对于像“User belongsTo Roles”这样的 hasOne/belongsTo 关系,你会得到一个 role 属性,而不是 RoleRoles

// A single entity (or null if not available)
$role = $user->role;

而对于另一个方向“Roles hasMany Users”,它将是

// Collection of user entities (or null if not available)
$users = $role->users;

加载关联

定义完关联后,你可以在获取结果时 急切加载关联