0 follower

Реляционная Active Record

Мы уже рассмотрели использование Active Record (AR) для выбора данных из одной таблицы базы данных. В этом разделе мы расскажем, как использовать AR для объединения нескольких связанных таблиц и получить объединенный набор данных.

AR полагается на метаданные об отношениях, чтобы определить, каким образом следует объединять таблицы, поэтому для использования реляционной AR необходимо, чтобы все связи отношения первичный-внешний ключ были четко определены между объединяемыми таблицами.

Примечание: Начиная с версии 1.0.1 можно использовать реляционную AR даже, если вы не определите ни одного ограничения с использованием внешних ключей в базе данных.

Для наглядности примеров в данном разделе мы будем использовать схему базы данных, представленную на этой диаграмме сущность-отношение (ER).

Диаграмма ER

Диаграмма ER

Информация: Поддержка ограничений по внешнему ключу различна в разных СУБД.

SQLite не поддерживает ограничений, но вы, тем не менее, можете их объявить при создании таблиц. AR может использовать эти данные для корректной поддержки реляционных запросов.

MySQL поддерживает ограничения по внешнему ключу в движке InnoDB и не поддерживает в MyISAM, поэтому для работы с MySQL рекомендуется использовать InnoDB. При использовании MyISAM для осуществления реляционных запросов посредством AR можно поступить следующим образом:

CREATE TABLE Foo
(
  id INTEGER NOT NULL PRIMARY KEY
);
CREATE TABLE bar
(
  id INTEGER NOT NULL PRIMARY KEY,
  fooID INTEGER
     COMMENT 'CONSTRAINT FOREIGN KEY (fooID) REFERENCES Foo(id)'
);

Выше мы используем ключевое слово COMMENT для пояснения ограничения по внешнему ключу, которое может быть использовано AR для распознания описанного отношения.

1. Объявление отношения

Перед тем, как использовать AR для реляционных запросов, нам необходимо объяснить AR, как AR-классы связаны друг с другом.

Отношение между двумя AR-классами напрямую зависит от отношений между соответствующими таблицами базы данных. С точки зрения БД, отношение между таблицами A и В может быть трех типов: один-ко-многим (например, User и Post), один-к-одному (например, User и Profile) и многие-ко-многим (например, Category и Post). В AR существует четыре типа отношений:

  • BELONGS_TO: если отношение между А и В один-ко-многим, значит В принадлежит А (например, Post принадлежит User);

  • HAS_MANY: если отношение между таблицами А и В один-ко-многим, значит у А есть много В (например, у User есть много Post);

  • HAS_ONE: это частный случай HAS_MANY, где А может иметь максимум одно В (например, у User есть только один Profile);

  • MANY_MANY: это отношение соответствует типу отношения многие-ко-многим в БД. Поскольку многие СУБД не поддерживают непосредственно тип отношения многие-ко-многим, требуется ассоциированная таблица для преобразования отношения многие-ко-многим в отношения один-ко-многим. В нашей схеме базы данных, этой цели служит таблица PostCategory. В терминологии AR отношение MANY_MANY можно описать как комбинацию BELONGS_TO и HAS_MANY. Например, Post принадлежит многим Category, а у Category есть много Post.

Объявляя отношение в AR, мы переопределяем метод relations() класса CActiveRecord. Этот метод возвращает массив с конфигурацией отношений. Каждый элемент массива представляет одно отношение в следующем формате:

'VarName'=>array('RelationType', 'ClassName', 'ForeignKey', ...дополнительные параметры)

где VarName - имя отношения, RelationType указывает на один из четырех типов отношения, ClassName - имя AR-класса, связанного с данным AR-классом, а ForeignKey - обозначает внешний(-ие) ключ(-и), используемый для связи. Кроме того, можно указать ряд дополнительных параметров, о которых расскажем чуть позже.

В коде ниже показано, как объявить отношение между классами User и Post.

class Post extends CActiveRecord
{
    public function relations()
    {
        return array(
            'author'=>array(self::BELONGS_TO, 'User', 'authorID'),
            'categories'=>array(self::MANY_MANY, 'Category', 'PostCategory(postID, categoryID)'),
        );
    }
}
 
class User extends CActiveRecord
{
    public function relations()
    {
        return array(
            'posts'=>array(self::HAS_MANY, 'Post', 'authorID'),
            'profile'=>array(self::HAS_ONE, 'Profile', 'ownerID'),
        );
    }
}

Информация: Если внешний ключ составной, мы должны объединить имена полей внешнего ключа и отделить друг от друга пробелом или запятой. Для типа отношения MANY_MANY имя ассоциативной таблицы также должно быть указано во внешнем ключе. Например, отношение categories в модели Post обозначено внешним ключом PostCategory(postID, categoryID).

При объявлении отношения в AR-классе для каждого отношения в класс неявно добавляется свойство. После выполнения реляционного запроса соответствующее свойство будет заполнено связанным(-и) экземпляром(-ами) AR. Например, если $author представляет AR-экземпляр User, то можно использовать $author->posts для доступа к связанным экземплярам Post.

2. Выполнение реляционного запроса

Самый простой способ выполнить реляционный запрос - считать реляционное свойство AR-класса. Если ранее к этому свойству никто не обращался, то будет инициирован реляционный запрос, который соединит связанные таблицы и оставит только данные, соответствующие первичному ключу текущего экземпляра AR. Результат запроса будет сохранен в свойстве как экземпляр(-ы) связанного класса. Этот подход также известен, как "ленивая загрузка" (lazy loading), т.е. реляционный запрос осуществляется только в момент первого обращения к связанным объектам. Пример ниже показывает использование этого подхода:

// получаем пост с ID=10
$post=Post::model()->findByPk(10);
// получаем автора поста: здесь будет выполнен реляционный запрос
$author=$post->author;

Информация: Если для отношения не существует связанного экземпляра, то соответствующее свойство будет null для отношений BELONGS_TO и HAS_ONE или пустым массивом для HAS_MANY и MANY_MANY. Стоит отметить, что отношения HAS_MANY и MANY_MANY возвращают массивы объектов и обращаться к их свойствам необходимо в цикле, иначе можно получить ошибку «Trying to get property of non-object».

Способ ленивой загрузки удобен , но не всегда эффективен. Например, если мы захотим получить информацию об авторе N постов, использование ленивого подхода потребует выполнения N запросов для объединения. В данной ситуации, нам поможет метод "жадной загрузки" (eager loading).

Этот подход заключается в загрузке всех связанных экземпляров AR одновременно с основным экземпляром AR. Реализуется этот подход путем использования в AR метода with() в связке с методом find или findAll. Например:

$posts=Post::model()->with('author')->findAll();

Код выше вернет массив экземпляров Post. В отличие от ленивого подхода, свойство author в каждом посте заполнено связанным экземпляром User еще до обращения к свойству. Вместо выполнения объединяющего запроса для каждого поста, жадная загрузка получает все посты вместе с авторами в одном объединяющем запросе!

В методе with() можно указать множество имен отношений и жадная загрузка вернет их за один раз. Например, следующий код вернет посты вместе с их авторами и категориями:

$posts=Post::model()->with('author','categories')->findAll();

Кроме того, можно осуществлять вложенную жадную загрузку. Для этого вместо простого списка имен отношений, мы передаем методу with() имена отношений, упорядоченных иерархически, как в примере ниже:

$posts=Post::model()->with(
    'author.profile',
    'author.posts',
    'categories')->findAll();

Пример выше вернет нам все посты с их авторами и категориями, а также профиль каждого автора и все его посты.

Примечание: Порядок использования метода with() изменился, начиная с версии 1.0.2. Пожалуйста, читайте внимательно соответствующую документацию API.

Реализация AR в Yii очень эффективна: для жадной загрузки иерархии связанных объектов с N отношениями типов HAS_MANY или MANY_MANY, потребуется только N+1 SQL-запрос для получения требуемого результата. Это значит, что в последнем примере потребуется 3 SQL-запроса из-за свойств posts и categories. Другие фреймворки практикуют более радикальный подход, используя только один SQL-запрос. На первый взгляд, этот подход кажется более эффективным вследствие меньшего количества запросов к СУБД. Однако, в действительности подход не практичен по двум причинам. Во-первых, в результате запроса возникает множество повторных столбцов данных, которые необходимо передать и обработать. Во-вторых, количество строк в результирующем множестве растет экспоненциально количеству задействованных таблиц, что делает этот процесс неуправляемым с увеличением количества отношений.

Начиная с версии 1.0.2, можно принудительно выполнить реляционный запрос через один SQL-запрос. Для этого просто добавьте вызов together() после with(). Например,

$posts=Post::model()->with(
    'author.profile',
    'author.posts',
    'categories')->together()->findAll();

Запрос выше будет выполнен с использованием одного SQL-запроса. Без вызова together потребуется три SQL-запроса: один связывает таблицы Post, User и Profile, второй - таблицы User и Post и третий - таблицы Post, PostCategory и Category.

3. Параметры реляционного запроса

Выше мы упоминали о том, что в реляционном запросе можно указать дополнительные параметры. Эти параметры - пары имя-значение - используются для тонкой настройки реляционного запроса. Список параметров представлен ниже.

  • select: список выбираемых полей для связанного AR-класса. По умолчанию значение параметра равно '*', т.е. выбираются все поля таблицы. Если поля используются в выражении (например, COUNT(??.name) AS nameCount), то необходимо однозначно указывать имена полей посредством aliasToken;

  • condition: соответствует оператору WHERE, по умолчанию значение параметра пустое. Имейте в виду, что ссылки на поля должны быть указаны однозначно посредством aliasToken (например, ??.id=10);

  • params: параметры для связывания в генерируемом SQL-выражении. Параметры передаются как массив пар имя-значение. Параметр доступен, начиная с версии 1.0.3;

  • on: соответствует оператору ON. Условие, указываемое в этом параметре, будет добавлено к условию объединения с использованием оператора AND. Имейте в виду, что ссылки на поля должны быть указаны однозначно посредством aliasToken (например, ??.id=10). Данный параметр неприменим для отношений типа MANY_MANY. Параметр доступен, начиная с версии 1.0.2;

  • order: соответствует оператору ORDER BY, по умолчанию значение параметра пустое. Имейте в виду, что ссылки на поля должны быть указаны однозначно посредством aliasToken (например, ??.age DESC);

  • with: список дочерних связанных объектов, которые должны быть загружены с самим объектом. Неправильное использование данной возможности может привести к бесконечному циклу.

  • joinType: тип объединения для отношения. По умолчанию значение параметра равно LEFT OUTER JOIN;

  • aliasToken: маркер префикса поля. Заменяется на соответствующий псевдоним таблицы для однозначного обозначения ссылок на поля. По умолчанию значение параметра равно '??.';

  • alias: псевдоним таблицы, ассоциированной с отношением. Этот параметр доступен с версии 1.0.1. По умолчанию значение параметра равняется null, что означает автоматическую генерацию псевдонима таблицы. Это отличает параметр от aliasToken, поскольку параметр
    aliasToken является просто маркером, который заменяется на фактический псевдоним таблицы;

  • together: параметр, устанавливающий необходимость принудительного объединения таблицы, ассоциированной с этим отношением, с основной таблицей. Этот параметр имеет смысл только для отношений типов HAS_MANY and MANY_MANY. Если параметр не установлен или равен false, тогда каждое отношение HAS_MANY или MANY_MANY будет иметь собственное выражение JOIN для улучшения производительности. Параметр доступен, начиная с версии 1.0.3;

  • group: соответствует оператору GROUP BY, по умолчанию значение параметра пустое. Имейте в виду, что ссылки на поля должны быть указаны однозначно посредством aliasToken (например, ??.age DESC);

  • having: соответствует оператору HAVING, по умолчанию значение параметра пустое. Имейте в виду, что ссылки на поля должны быть указаны однозначно посредством aliasToken (например, ??.age DESC). Параметр доступен, начиная с версии 1.0.1.

  • index: имя столбца, значения которого должны быть использованы в качестве ключей массива, хранящего связанные объекты. Без установки этого параметра, массив связанных объектов использует целочисленный индекс, начинающийся с нуля. Параметр может быть установлен только для отношений HAS_MANY и MANY_MANY. Параметр доступен с версии 1.0.7.

Кроме того, для отложенной загрузки некоторых типов отношений доступен ряд дополнительных параметров:

  • limit: параметр для ограничения количества строк в выборке. Параметр не применим для отношений BELONGS_TO;

  • offset: параметр для указания начальной строки выборки. Параметр не применим для отношений BELONGS_TO.

Ниже мы изменим определение отношения в модели User, добавив несколько вышеприведенных параметров:

class User extends CActiveRecord
{
    public function relations()
    {
        return array(
            'posts'=>array(self::HAS_MANY, 'Post', 'authorID',
                            'order'=>'??.createTime DESC',
                            'with'=>'categories'),
            'profile'=>array(self::HAS_ONE, 'Profile', 'ownerID'),
        );
    }
}

Теперь, если мы обратимся к $author->posts, то получим все посты автора, упорядоченные по времени создания по убыванию. Также каждый экземпляр поста содержит свои уже подгруженные категории.

Информация: Когда поле с одинаковым именем присутствует в нескольких объединяемых таблицах, их необходимо указать однозначно. Делается это с помощью добавления к имени поля префикса - имени таблицы. Например, id станет Team.id. В реляционных запросах AR сделать так просто не получится, т.к. SQL-выражения генерируются AR автоматически с выдачей псевдонима для каждой таблицы. Поэтому во избежание конфликта имен полей, мы используем маркер для полей, имена которых необходимо указать однозначно, а AR самостоятельно заменит маркер на подходящий псевдоним таблицы.

4. Динамические параметры реляционного запроса

Начиная с версии 1.0.2, мы можем использовать динамические параметры как для параметра with(), так и для параметра with. Динамические параметры переопределяют существующие параметры в соответствии с описанием метода relations(). К примеру, если для модели User, приведенной выше, мы хотим воспользоваться жадной загрузкой для получения постов автора в порядке возрастания (параметр order в определении отношения задает убывающий порядок), можно сделать это следующим образом:

User::model()->with(array(
    'posts'=>array('order'=>'??.createTime ASC'),
    'profile',
))->findAll();

Начиная с версии 1.0.5 динамические параметры в реляционных запросах можно использовать вместе с отложенной загрузкой. Для этого необходимо вызвать метод с тем же именем, что и имя связи, и передать параметры как его аргумент. К примеру, следующий код вернёт публикации пользователя, у которых status равен 1:

$user=User::model()->findByPk(1);
$posts=$user->posts(array('condition'=>'status=1'));

5. Статистический запрос

Примечание: статистические запросы доступны, начиная с версии 1.0.4.

Помимо реляционных запросов, описанных выше, Yii также поддерживает так называемые статистические запросы (или запросы агрегирования). Этот тип запросов используется для получения агрегированных данных, относящихся к связанным объектам, например количество комментариев к каждому посту, средний рейтинг для каждого наименования продукции и т.д. Статистические запросы могут быть использованы только для объектов, связанных отношениями HAS_MANY (например, у поста есть много комментариев) или MANY_MANY (например, пост принадлежит многим категориям, а к категории относится множество постов).

Выполнение статистического запроса аналогично выполнению реляционного запроса в соответствии с описанием выше. Первым делом необходимо объявить статистический запрос в методе relations() класса CActiveRecord.

class Post extends CActiveRecord
{
    public function relations()
    {
        return array(
            'commentCount'=>array(self::STAT, 'Comment', 'postID'),
            'categoryCount'=>array(self::STAT, 'Category', 'PostCategory(postID, categoryID)'),
        );
    }
}

Выше мы объявили два статистических запроса: commentCount подсчитывает количество комментариев к посту, а categoryCount считает количество категорий, к которым относится пост. Обратите внимание, что отношение между Post и Comment - типа HAS_MANY, а отношение между Post и Category - типа MANY_MANY (с использованием преобразующей таблицы PostCategory). Как можно видеть, порядок объявления очень схож с объявлением отношений, описанных выше. Единственное различие состоит в том, что в данном случае тип отношения равен STAT.

За счет объявленных отношений мы можем получить количество комментариев для поста, используя выражение $post->commentCount. В момент первого обращения к данному свойству для получения соответствующего результата неявным образом выполняется SQL-выражение. Как мы уже говорили, это называется подходом ленивой загрузки. Можно также использовать жадный вариант загрузки, если необходимо получить количество комментариев к нескольким постам:

$posts=Post::model()->with('commentCount', 'categoryCount')->findAll();

Выражение выше выполняет три SQL-запроса для получения всех постов вместе с значениями количества комментариев к ним и количества категорий. В случае ленивой загрузки нам бы понадобилось выполнить 2*N+1 SQL-запросов для N постов.

По умолчанию статистический запрос считает количество с использованием выражения COUNT. Его можно уточнить путем указания дополнительных параметров в момент объявления в методе relations(). Доступные параметры перечислены ниже:

  • select: статистическое выражение, по умолчанию равно COUNT(*), что соответствует количеству дочерних объектов;

  • defaultValue: значение, которое присваивается в случае, если результат статистического запроса для записи отрицателен. Например, если пост не имеет ни одного комментария, то свойству commentCount будет присвоено это значение. По умолчанию значение данного параметра равно 0;

  • condition: соответствует оператору WHERE, по умолчанию значение параметра пустое;

  • params: параметры для связывания в генерируемом SQL-выражении. Параметры передаются как массив пар имя-значение;

  • order: соответствует оператору ORDER BY, по умолчанию значение параметра пустое;

  • group: соответствует оператору GROUP BY, по умолчанию значение параметра пустое;

  • having: соответствует оператору HAVING, по умолчанию значение параметра пустое.

6. Реляционные запросы с именованными группами условий

Примечание: Группы условий поддерживаются, начиная с версии 1.0.5.

В реляционном запросе именованные группы условий могут быть использованы двумя способами. Их можно применить к основной модели и к связанным моделям.

Следущий код показывает случай с основной моделью:

$posts=Post::model()->published()->recently()->with('comments')->findAll();

Данный код очень похож на нереляционные запросы. Единственное отличие в том, что у нас присутствует вызов with() после вызовов групп условий. Данный запрос вернёт недавно опубликованные записи вместе с комментариями к ним.

В следующем примере показано, как применить группы условий к связанным моделям:

$posts=Post::model()->with('comments:recently:approved')->findAll();

Этот запрос вернёт все записи вместе с одобренными комментариями. Здесь comments относится к имени отношения. recently и approved — именованные группы, описанные в модели Comment. Имя отношения и группы параметров разделяются двоеточием.

Именованные группы могут быть использованы при описании отношений модели в методе CActiveRecord::relations() в параметре with. В следующем примере при обращении к $user->posts вместе с публикациями будут получены все одобренные комментарии.

class User extends CActiveRecord
{
    public function relations()
    {
        return array(
            'posts'=>array(self::HAS_MANY, 'Post', 'authorID',
                'with'=>'comments:approved'),
        );
    }
}

Информация: Именованные группы параметров, применяемые к реляционным моделям, должны описываться в методе CActiveRecord::scopes, поэтому они не могут быть параметризованы.