Возможно, всё что я напишу ниже – очевидно, и все этим пользуются давно, но я вот недавно только это понял и придумал, так что, может, кому и пригодится.


Yii2 и расширение yii2-mongodb к сожалению, не работает с вложенными документами, тем самым оставляя за бортом существенное преимущество документоориентированной БД.
В документации предлагают использовать расширение для вложенных документов, но можно обойтись и без него.
Предположим, у нас есть модель, формирующая PDF-файл для загрузки, и мы хотим следить за количеством его скачиваний, IP-адресами скачавших и, например, временем, когда файл был загружен.


Для простоты я предполагаю, что сам файл хранится в строке, но это, конечно, может быть совсем не так – он может лежать где-то в хранилище или формироваться функцией.


Основная модель (часть)


/**
 * @property string $pdf_data       стока с данным, которая потом преобразуется в файл
 * @property array $downloads_data  здесь хранятся сведения о загрузках
 *
 */
Class PdfData extends \yii\mongodb\ActiveRecord

/** @inheritdoc */
public static function collectionName()
{
    return [‘database’, ‘pdf’]
}

/** @inheritdoc */
public function attributes()
{
    return [
        ‘pdf_data’,
        ‘downloads_data’
    ];
}

Дополнительная модель – для проверки и присвоения значений элементам массива



use \MongoDB\BSON\UTCDateTime

/**
 * Класс для формирования сведений о факте загрузки файла
 */
class DowmnloadData extends \yii\base\Model
{
    /** @var \MongoDB\BSON\UTCDateTime $datetime */
    public $datetime;
    /** @var string $clientIp */
    public $clientIp;
    /** @var string $clientHost */
    public $clientHost;
    /** @var string $clientUserAgent */
    public $clientUserAgent;
    /** @var string $referer */
    public $referer;
    /** @var bool $result */
    public $result = false;

    /** @inheritdoc */
    public function rules()
    {
        return [
            ['datetime', 'default', 'value' => function() 
                   { return new UTCDateTime(strtotime("now") * 1000); }],
            ['clientIp', 'default', 'value' => function() 
                   { return Yii::$app->request->getUserIP(); }],
            ['clientHost', 'default', 'value' => function() 
                   { return Yii::$app->request->getUserHost(); }],
            ['clientUserAgent', 'default', 'value' => function() 
                   { return Yii::$app->request->getUserAgent(); }],
            ['referer', 'default', 'value' => function() 
                   { return Yii::$app->request->getReferrer(); }],
            ['result', 'boolean'],
        ];
    }

Далее, в действии контроллера, которое отдает файл наружу, примерно следующее:


// -- skip --

/**
 * @param string $id идентификатор основной модели
 * @return null
 * @throws \yii\web\NotFoundHttpException
 */
public function actionDownload($id)
{
    if(($model = PdfData::findOne($id)) === null)
        throw new \yii\web\NotFoundHttpException(Yii::t('app', 'File not found'));

    $downloadData = new DowmnloadData();
    if(!empty($model->pdf_data))
    {
        $downloadData->result = true;
        $downloadData->validate(); // Так мы добиваемся присвоения значений по-умолчанию
        $data = $model->downloads_data; // Забрали существующие сведения о загрузках
        $data[] = $downloadData->attributes; // Добавили к ним новые
        // array_values для гарантироанного сохранения массива (а не объекта) в mongodb
        $model->updateAttributes(['downloads_data' => array_values($data)]); 

        // Отправляем файл
        Yii::$app->response->sendContentAsFile($model->pdf_data, 'we are the champions.pdf', [
            'mimeType' => 'text/xml',
            'inline' => true
        ]);
    }
    return null;
}

Таким образом внутри массива downloads_data основной модели мы имеем все атрибуты, которые придумали в DowmnloadData, и можем их потом как угодно показывать и анализировать, не умножая сверх необходимого при этом ни атрибуты основной модели, ни число коллекций в БД.

Поделиться с друзьями
-->

Комментарии (7)


  1. ErickSkrauch
    30.09.2016 17:09

    А где, собсно, валидация вложенных атрибутов? Я ожидал чего-то в стиле:

    public function rules() {
        return [
            [['field.child1.child2'], 'required'],
            [['anotherField.hisChild'], 'string', 'max' => 20],
        ];
    }
    

    Ну или чего-то в таком стиле. А тут виден только булев валидатор на поле result и использование валидаторов для загрузки значений по умолчанию, что немного вообще мимо кассы.


    1. andrew72ru
      30.09.2016 17:18
      -1

      Так по обстоятельствам. В данном-то случае и нужно только автоприсвоение и ничего более, но кто ж мешает что-то ещё добавить? И, например, в форму вывести.
      Я же показываю принцип, а не пишу пошаговое руководство.


      1. ErickSkrauch
        30.09.2016 17:21

        Так не видно принципа ведь :)

        Может стоит хотя бы поменять заголовок? Не хочу обидеть, но сюда просится «Использование модели для предварительной валидации данных для другой модели» и это довольно стандартный подход, когда, скажем, форма регистрации создаётся через отдельную модель, со своими полями и правилами, а не путём запихивания валидаторов и сценариев в модель пользователя.


        1. andrew72ru
          30.09.2016 17:35

          Я же говорю:


          Возможно, всё что я напишу ниже – очевидно, и все этим пользуются давно, но я вот недавно только это понял и придумал, так что, может, кому и пригодится.

          Мне лично как-то неочевидно было до сих пор, к сожалению :)


          И да, насчет заголовка – конечно, мы в данном случае используем \yii\base\Model для валидации (и присвоения значений) другой модели (что, в общем, в примерах есть), но и привязка к mongodb и её вложенным документам тоже важна, как по мне. Именно про это речь.


    1. PaulZi
      03.10.2016 12:51
      +1

      Я делал себе недавно такой валидатор: https://gist.github.com/paulzi/ad27c4689475ca442a2ea5880d659ff3

      Использовать так:

      public function rules() {
          return [
              ['params'], CompositeValidator::className(), 'rules' => [
                  [['orderCallback'], 'match', 'pattern' => '|^https?://.+|'],
                  [['orderCallback'], 'default', 'value' => null],
                  [['calc', 'calcOasisPriceType'], 'filter', 'filter' => 'boolval'],
                  [['factor'], 'filter', 'filter' => 'floatval'],
                  [['calcOasisFactor'], 'default', 'value' => null],
                  [['fields[].title'], 'required'],
              ]],
          ];
      }
      


      Если будет интересно, могу оформить в composer.

      Правда есть тонкость, чтобы в Yii2 корректно показывалось в каком именно поле ошибка, нужно переопределить Html::getAttributeName(), а то там вырезается вложенность:
          public static function getAttributeName($attribute)
          {
              if (preg_match('/(^|.*\])([\w\.]+)(\[.*|$)/', $attribute, $matches)) {
                  return $matches[2] . $matches[3];
              } else {
                  throw new InvalidParamException('Attribute name must contain word characters only.');
              }
          }
      


      1. ErickSkrauch
        03.10.2016 13:10

        Отличная задумка. Я сам уже пытался сделать нечто подобное, но мои попытки разбились о то, что придётся переписывать код из Model и Validator, что отвечает за создание и подключение валидатора. Тебя, судя по коду, это не смутило и ты заставил это работать :)

        Оформи как Composer пакет, я постараюсь тоже сделать свой вклад.


        1. PaulZi
          03.10.2016 14:47

          На самом деле многое взял из стандартного EachValidator. Ок, будет время — сделаю)