Строим блог на Areto фреймворк

Модель статьи

Класс модели наследуется от класса Article из публичной части.

Модель статьи

Правило safe, которое применяется к атрибуту files, не производит никаких проверок, но обозначает, что атрибуту может быть присвоено значение из данных формы.

module/admin/model/Article.js

const Base = require('../../../model/Article');
module.exports = class Article extends Base {
  static getConstants () {
    return {
      ATTRS: [
        'status',
        'authorId',
        'date',
        'title',
        'content',
        'mainPhotoId'
      ],
      RULES: [
        [['title', 'content', 'status', 'date'], 'required'],
        ['title', 'string', {min: 3, max: 128}],
        ['title', 'unique'],
        ['content', 'string', {min: 10, max: 16128}],
        ['date', 'date'],
        ['status', 'range', {range: [
          this.STATUS_DRAFT,
          this.STATUS_PUBLISHED,
          this.STATUS_ARCHIVED,
          this.STATUS_BLOCKED
        ]}],
        ['files', 'safe'],
        ['tags', 'validateTags', {skipOnAnyError: true}]
      ],
      UNLINK_ON_REMOVE: ['photos', 'comments'],
      ATTR_VALUE_LABELS: {
        'status': {
          [this.STATUS_DRAFT]: 'Draft',
          [this.STATUS_PUBLISHED]: 'Published',
          [this.STATUS_ARCHIVED]: 'Archived',
          [this.STATUS_BLOCKED]: 'Blocked'
        }
      }
    };
  }
  // place methods here
};
module.exports.init(module);
const ArrayHelper = require('areto/helper/ArrayHelper');
const Comment = require('./Comment');
const Tag = require('./Tag');
const File = require('./File');
const Photo = require('./Photo');
const User = require('./User');

Метод findBySearch ищет статьи, у которых заголовок содержит указанный текст.

module/admin/model/Article.js

static findBySearch (text) {
  let query = this.find();
  if (typeof text === 'string' && /[a-z0-9\-\s]{1,32}/i.test(text)) {
    query.and(['LIKE','title', `%${text}%`]);
  }
  return query;
}

Метод findToSelect возвращает запрос на выборку всех заголовков статей. Модификатор asRaw устанавливает результатом выборки не массив моделей, а массив простых JavaScript объектов.

module/admin/model/Article.js

static findToSelect () {
  return this.find().select(['title']).asRaw();
}

По умолчанию новая статья получает статус «Черновик».

module/admin/model/Article.js

constructor (config) {
  super(config);
  this.set('status', this.STATUS_DRAFT);
}

Обработка фотографий

Обработчик beforeValidate вызывается перед валидацией модели. Для корректной работы механизма событий необходимо вызвать асинхронный родительский метод.

module/admin/model/Article.js

async beforeValidate () {
  await super.beforeValidate();
  await this.resolveFiles(this.get('files'));
}

Обработчик afterSave вызывается после успешного сохранения модели. Аргумент insert содержит флаг, определяющий создание или редактирование модели текущим сохранением.

module/admin/model/Article.js

async afterSave (insert) {
  await super.afterSave(insert);
  await this.createPhotos(this.get('files'));
}

Асинхронный метод resolveFiles находит модели загруженных файлов, соответствующие идентификаторам, переданным с формы в сериализованном массиве files.

module/admin/model/Article.js

async resolveFiles (files) {
  if (files && typeof files === 'string') {
    this.set('files', await File.findById(files.split(',')).all());
    await PromiseHelper.setImmediate();
  }
}

Асинхронный метод createPhotos создает модели Photo из загруженных файлов и связывает их с текущей статьей. Если не задано главное фото статьи mainPhotoId, то им назначается первое из созданных фотографий.

module/admin/model/Article.js

createPhotos (files) {
  if (!(files instanceof Array)) {
    return false;
  }
  let photos = [];
  for (let file of files) {
    let photo = await this.createPhoto(file);
    if (photo) {
      photos.push(photo);
    }
    await PromiseHelper.setImmediate();
  }
  if (photos.length && this.get('mainPhotoId')) {
    // set first photo as main
    this.set('mainPhotoId', photos[0].getId());
    this.set('files', null);
    await this.forceSave();
  }
}

async createPhoto (file) {
  let photo = new Photo;
  photo.set('articleId', this.getId());
  photo.set('file', file);
  try {
    if (await photo.save()) {
      return photo;
    }
  } catch (err) {
    this.log('error', err);
  }
}

Обработка меток

С формы приходит сериализованный список идентификаторов меток, которые связаны со статьей. Метод validateTags фильтрует их от пустых и неуникальных значений, разрывает текущие связи с метками и создает новые из полученного списка.

module/admin/model/Article.js

async validateTags (attr, params) {
  let items = this.get(attr);
  if (typeof items !== 'string') {
    return;
  }
  items = items.split(',').map(item => item.trim()).filter(item => item);
  items = ArrayHelper.unique(items);
  await this.unlinkAll('tags');
  for (let item of items) {
    await this.resolveTag(item);
  }
}

Метод resolveTag находит или создает новую модель метки по указанному имени и связывает ее с текущей статьей.

module/admin/model/Article.js

resolveTag (name) {
  let model = await Tag.findByName(name).one();
  if (model) {
    return this.link('tags', model);
  }
  model = new Tag;
  model.set('name', name);
  if (await model.save()) {
    await this.link('tags', model);
  }
}

Отношения статьи

Отношение relAuthor определяет автора статьи.

Отношение relPhotos определяет фотографии, относящиеся к статье.

Отношение relMainPhoto определяет главное фото статьи.

Отношение relComments определяет комментарии, относящиеся к статье. Последний аргумент в методе hasMany отвечает за удаление комментариев при разрыве связи со статьей.

Отношение relTags определяет метки, относящиеся к статье. Для связи используется промежуточная таблица rel_article_tag.

module/admin/model/Article.js

relAuthor () {
  return this.hasOne(User, User.PK, 'authorId');
}

relPhotos () {
  return this.hasMany(Photo, 'articleId', this.PK);
}

relMainPhoto () {
  return this.hasOne(Photo, Photo.PK, 'mainPhotoId');
}

relComments () {
  return this.hasMany(Comment, 'articleId', this.PK)
    .removeOnUnlink();
}

relTags () {
  return this.hasMany(Tag, Tag.PK, 'tagId')
    .viaTable('rel_article_tag', 'articleId', this.PK)
    .removeOnUnlink();
}