hasAndBelongsToMany (HABTM)

现在,你已经是 CakePHP 模型关联的专家了。你已经深谙对象关系中的三种关联。

现在我们来解决最后一种关系类型: hasAndBelongsToMany,也称为 HABTM。 这种关联用于两个模型需要多次重复以不同方式连接的场合。

hasMany 与 HABTM 主要不同点是 HABTM 中对象间的连接不是唯一的。例如,以 HABTM 方式连接 Recipe 模型和 Ingredient 模型。西红柿不只可以作为我奶奶意大利面(Recipe)的成分(Ingredient),我也可以用它做色拉(Recipe)。

hasMany 关联对象间的连接是唯一的。如果我们的 User hasMnay Comments,一个评论仅连接到一个特定的用户。它不能被再利用。

继续前进。我们需要在数据库中设置一个额外的表,用来处理 HABTM 关联。这个新连接表的名字需要包含两个相关模型的名字,按字母顺序并且用下划线(_)间隔。表的内容有两个列,每个外键(整数类型)都指向相关模型的主键。为避免出现问题 - 不要为这个两个列定义复合主键,如果应用程序包含复合主键,你可以定义一个唯一的索引(作为外键指向的键)。如果你计划在这个表中加入任何额外的信息,或者使用 ‘with’ 模型,你需要添加一个附加主键列(约定为 ‘id’)

HABTM 包含一个单独的连接表,其表名包含两个 模型 的名字。

关系 HABTM 表列
Recipe HABTM Ingredient ingredients_recipes.id, ingredients_recipes.ingredient_id,ingredients_recipes.recipe_id
Cake HABTM Fan cakes_fans.id, cakes_fans.cake_id, cakes_fans.fan_id
Foo HABTM Bar bars_foos.id, bars_foos.foo_id, bars_foos.bar_id

注解

按照约定,表名是按字母顺序组成的。在关联定义中自定义表名是可能的。

确保表 cakes 和 recipes 遵循了约定,由表中的 id 列担当主键。如果它们与假定的不同,模型的 主键 必须被改变。

一旦这个新表被建立,我们就可以在模型文件中建立 HABTM 关联了。这次我们将直接跳到数组语法:

class Recipe extends AppModel {
    public $hasAndBelongsToMany = array(
        'Ingredient' =>
            array(
                'className'              => 'Ingredient',
                'joinTable'              => 'ingredients_recipes',
                'foreignKey'             => 'recipe_id',
                'associationForeignKey'  => 'ingredient_id',
                'unique'                 => true,
                'conditions'             => '',
                'fields'                 => '',
                'order'                  => '',
                'limit'                  => '',
                'offset'                 => '',
                'finderQuery'            => '',
                'deleteQuery'            => '',
                'insertQuery'            => ''
            )
    );
}

HABTM 关联数组可能包含的键有:

  • className: 关联到当前模型的模型类名。如果你定义了 ‘Recipe HABTM Ingredient’ 关系,这个类名将是 ‘Ingredient.’

  • joinTable: 在本关联中使用的连接表的名字(如果当前表没有按照 HABTM 连接表的约定命名的话)。

  • with: 为连接表定义模型名。默认的情况下,CakePHP 将自动为你建立一个模型。上例中,它被称为 IngredientsRecipe。你可以使用这个键来覆盖默认的名字。连接表模型能够像所有的 “常规” 模型那样用来直接访问连接表。通过建立带有相同类名和文件名的模型类,你可以向连接表搜索中加入任何自定义行为,例如向其加入更多的信息/列。

  • foreignKey: 当前模型中需要的外键。用于需要定义多个 HABTM 关系。其默认值为当前模型的单数模型名缀以 ‘_id’。

  • associationForeignKey: 另一张表中的外键名。用于需要定义多个 HABTM 关系。其默认值为另一模型的单数模型名缀以 ‘_id’。

  • unique: 布尔值或者字符串 keepExisting
    • 如果为 true (默认值),Cake 将在插入新行前删除外键表中存在的相关记录。现有的关系在更新时需要再次传递。
    • 如果为 false,Cake 将插入相关记录,并且在保存过程中不删除连接记录。
    • 如果设置为 keepExisting,其行为与 true 相同,但现有关联不被删除。
  • conditions: 个 find() 兼容条件的数组或者 SQL 字符串。如果在关联表上设置了条件,需要使用 ‘with’ 模型,并且在其上定义必要的 belongsTo 关联。

  • fields: 需要在匹配的关联模型数据中获取的列的列表。默认返回所有的列。

  • order: 一个 find() 兼容排序子句或者 SQL 字符串。

  • limit: 想返回的关联行的最大行数。

  • offset: 获取和关联前要跳过的行数(根据提供的条件 - 多数用于分页时的当前页的偏移量)。

  • finderQuery, deleteQuery, insertQuery: CakePHP 能用来获取、删除或者建立新的关联模型记录的完整 SQL 查询语句。用在包含很多自定义结果的场合。

一旦关联被创建,Recipe 模型上的 find 操作将可同时获取到相关的 Tag 记录(如果它们存在的话):

// 调用 $this->Recipe->find() 的结果示例。

Array
(
    [Recipe] => Array
        (
            [id] => 2745
            [name] => Chocolate Frosted Sugar Bombs
            [created] => 2007-05-01 10:31:01
            [user_id] => 2346
        )
    [Ingredient] => Array
        (
            [0] => Array
                (
                    [id] => 123
                    [name] => Chocolate
                )
           [1] => Array
                (
                    [id] => 124
                    [name] => Sugar
                )
           [2] => Array
                (
                    [id] => 125
                    [name] => Bombs
                )
        )
)

如果在使用 Ingredient 模型时想获取 Recipe 数据,记得在 Ingredient 模型中定义 HABTM 关联。

注解

HABTM 数据被视为完整的数据集。每次一个新的关联数据被加入,数据库中的关联行的完整数据集被删除并重新建立。所以你总是需要为保存操作传递整个数据集。使用 HABTM 的另一方法参见 hasMany 贯穿 (连接模型)

小技巧

关于保存 HABTM 对象的更多信息请参见: 保存相关模型数据 (HABTM)

hasMany 贯穿 (连接模型)

有时候需要存储带有多对多关系的附加数据。考虑以下情况:

Student hasAndBelongsToMany Course

Course hasAndBelongsToMany Student

换句话说,一个 Student 可以有很多 Courses,而一个 Course 也能有多个 Student。 这个简单的多对多关联需要一个类似于如下结构的表:

id | student_id | course_id

现在,如果我们要存储学生在课程上出席的天数及他们的最终级别,这张表将变成:

id | student_id | course_id | days_attended | grade

问题是,hasAndBelongsToMany 不支持这类情况,因为 hasAandBelongsToMany 关联被存储时,先要删除这个关联。列中的额外数据会丢失,且放到新插入的数据中。

在 2.1 版更改.

你可以将 unique 设置为 keepExisting 防止在保存过程丢失额外的数据。参阅 HABTM association arrays 中的 unique 键。

实现我们的要求的方法是使用一个 连接模型,或者也称为 hasMany 贯穿 关联。 具体作法是模型与自身关联。现在我们建立一个新的模型 CourseMembership。下面是此模型的定义。

// Student.php
class Student extends AppModel {
    public $hasMany = array(
        'CourseMembership'
    );
}

// Course.php

class Course extends AppModel {
    public $hasMany = array(
        'CourseMembership'
    );
}

// CourseMembership.php

class CourseMembership extends AppModel {
    public $belongsTo = array(
        'Student', 'Course'
    );
}

CourseMembership 连接模型唯一标识了一个给定的学生额外参与的课程,存入扩展元信息中。

连接表非常有用,Cake 使其非常容易地与内置的 hasMany 和 belongsTo 关联及 saveAll 特性一同使用。

在运行期间创建和销毁关联

有时候需要在运行时建立和销毁模型关联。比如以下几种情况:

  • 你想减少获取的关联数据的量,但是你的所有关联都是循环的第一级。
  • 你想要改变定义关联的方法以便排序或者过滤关联数据。

这种关联的建立与销毁由 CakePHP 模型 bindModel() 和 unbindModel() 方法完成。(还有一个非常有用的行为叫 “Containable”,更多信息请参阅手册中 内置行为 一节)。 我们来设置几个模型,看看 bindModel() 和 unbindModel() 如何工作。我们从两个模型开始:

class Leader extends AppModel {
    public $hasMany = array(
        'Follower' => array(
            'className' => 'Follower',
            'order'     => 'Follower.rank'
        )
    );
}

class Follower extends AppModel {
    public $name = 'Follower';
}

现在,在 LeaderController 控制器中,我们能够使用 Leader 模型的 find() 方法获取一个 Leader 和它的 追随者(followers)。就像你上面看到的那样,Leader 模型的关联关系数组定义了 “Leader hasMany Followers” 关系。为了演示一下实际效果,我们使用 unbindModel() 删除控制器动作中的关联:

public function some_action() {
    // 获取 Leaders 及其相关的 Followers
    $this->Leader->find('all');

    // 删除 hasMany...
    $this->Leader->unbindModel(
        array('hasMany' => array('Follower'))
    );

    // 现在使用 find 函数将只返回 Leaders,没有 Followers
    $this->Leader->find('all');

    // NOTE: unbindModel 只影响紧随其后的 find 函数。再往后的 find 调用仍将使用预配置的关联信息。

    // 我们已经在 unbindModel() 之后使用了 find('all'),
    // 所以此处将再次获取 Leaders 及与其相关的 Followers ...
    $this->Leader->find('all');
}

注解

使用 bindModel() 和 unbindModel() 来添加和删除关联,仅在紧随其后的 find 操作中有效,除非第二个参数设置为 false。如果第二个参数被设置为 false,请求的剩余位置仍将保持 bind 行为。

以下是 unbindModel() 的基本用法模板:

$this->Model->unbindModel(
    array('associationType' => array('associatedModelClassName'))
);

现在我们成功地在运行过程中删除了一个关联。 让我们来添加一个。我们到今天仍没有原则的领导需要一些关联的原则。我们的 Principle 模型文件除了 public $name 声明之外,什么都没有。 我们在运行中给我们的领导关联一些 Principles(谨记它仅在紧随其后的 find 操作中有效)。在 LeadersController 中的函数如下:

public function another_action() {
    // 在 leader.php 文件中没有 Leader hasMany Principles 关联,所以这里的 find 只获取了 Leaders。
    $this->Leader->find('all');

    // 我们来用 bindModel() 为 Leader 模型添加一个新的关联:
    $this->Leader->bindModel(
        array('hasMany' => array(
                'Principle' => array(
                    'className' => 'Principle'
                )
            )
        )
    );

    // 现在我们已经正确的设置了关联,我们可以使用单个的 find 函数来获取带有相关 principles 的 Leader:
    $this->Leader->find('all');
}

bindModel() 的基本用法是封装在以你尝试建立的关联类型命名的数组中的常规数组:

$this->Model->bindModel(
    array('associationName' => array(
            'associatedModelClassName' => array(
                // normal association keys go here...
            )
        )
    )
);

即使不需要通过绑定模型对模型文件中的关联定义做任何排序,仍然需要为使新关联正常工作设置正确的排序键。

同一模型上的多个关系

有时一个模型有多个与其它模型的关联。例如,你可能需要有一个拥有两个 User 模型的 Message 模型。一个是要向其发送消息的用户,一个是从其接收消息的用户。 消息表有一个 user_id 列,还有一个 recipient_id。 你的消息模型看起来就像下面这样:

class Message extends AppModel {
    public $belongsTo = array(
        'Sender' => array(
            'className' => 'User',
            'foreignKey' => 'user_id'
        ),
        'Recipient' => array(
            'className' => 'User',
            'foreignKey' => 'recipient_id'
        )
    );
}

Recipient 是 User 模型的别名。来瞧瞧 User 模型是什么样的:

class User extends AppModel {
    public $hasMany = array(
        'MessageSent' => array(
            'className' => 'Message',
            'foreignKey' => 'user_id'
        ),
        'MessageReceived' => array(
            'className' => 'Message',
            'foreignKey' => 'recipient_id'
        )
    );
}

它也可以建立如下的自关联:

class Post extends AppModel {

    public $belongsTo = array(
        'Parent' => array(
            'className' => 'Post',
            'foreignKey' => 'parent_id'
        )
    );

    public $hasMany = array(
        'Children' => array(
            'className' => 'Post',
            'foreignKey' => 'parent_id'
        )
    );
}

获取关联记录的嵌套数组:

如果表里有 parent_id 使用不带任何关联设置的单个查询的 find(‘threaded’) 来获取记录的嵌套数组。

连接表

在 SQL 中你可以使用 JOIN 子句绑定相关表。 这允许你运行跨越多个表的复杂查询(例如:按给定的几个 tag 搜索帖子)。

在 CakePHP 中一些关联(belongsTo 和 hasOne)自动执行 join 以检索数据,所以你能发出根据相关数据检索模型的查询。

但是这不适用于 hasMany 和 hasAndBelongsToMany 关联。这些地方需要强制向循环中添加 join。你必须定义与要联合的表的必要连接(join),使你的查询获得期望的结果。

注解

谨记,你需要将 recursion 设置为 -1,以使其正常工作。例如: $this->Channel->recursive = -1;

在表间强制添加 join 时,你需要在调用 Model::find() 时使用 “modern” 语法,在 $options 数组中添加 ‘joins’ 键。例如:

$options['joins'] = array(
    array('table' => 'channels',
        'alias' => 'Channel',
        'type' => 'LEFT',
        'conditions' => array(
            'Channel.id = Item.channel_id',
        )
    )
);

$Item->find('all', $options);

注解

注意 ‘join’ 数组不是一个键。

在上面的例子中,叫做 Item 的模型 left join 到 channels 表。你可以用模型名为表起别名,以使检索到的数组完全符合 CakePHP 的数据结构。

定义 join 所用的键如下:

  • table: 要连接的表。
  • alias: 表的别名。最好使用关联模型名。
  • type: 连接类型: inner, left 或者 right。
  • conditions: 执行 join 的条件。

对于 joins 选项,你可以添加基于关系模型列的条件:

$options['joins'] = array(
    array('table' => 'channels',
        'alias' => 'Channel',
        'type' => 'LEFT',
        'conditions' => array(
            'Channel.id = Item.channel_id',
        )
    )
);

$options['conditions'] = array(
    'Channel.private' => 1
);

$privateItems = $Item->find('all', $options);

你可以在 hasAndBelongsToMany 中运行几个需要的 joins:

假定一个 Book hasAndBelongsToMany Tag。这个关系使用一个 books_tags 表合为连接表,你需要连接 books 表和 books_tags 表,并且带着 tags 表::

$options['joins'] = array(
    array('table' => 'books_tags',
        'alias' => 'BooksTag',
        'type' => 'inner',
        'conditions' => array(
            'Books.id = BooksTag.books_id'
        )
    ),
    array('table' => 'tags',
        'alias' => 'Tag',
        'type' => 'inner',
        'conditions' => array(
            'BooksTag.tag_id = Tag.id'
        )
    )
);

$options['conditions'] = array(
    'Tag.tag' => 'Novel'
);

$books = $Book->find('all', $options);

使用 joins 允许你以极为灵活的方式处理 CakePHP 的关系并获取数据,但是在很多情况下,你能使用其它工具达到同样的目的,例如正确地定义关联,运行时绑定模型或者使用 Containable 行为。使用这种特性要很小心,因为它在某些情况下可能会带来模式不规范的 SQL 查询。