OurBigBook
web/models/index.js
const assert = require('assert')
const fs = require('fs')
const path = require('path')

const { DatabaseError, Sequelize, DataTypes } = require('sequelize')

const ourbigbook_models = require('ourbigbook/models')
const ourbigbook_nodejs_webpack_safe = require('ourbigbook/nodejs_webpack_safe');
const { sequelizeCreateTrigger, sequeliezeCreateTriggerUpdateCount } = ourbigbook_nodejs_webpack_safe

const config = require('../front/config')

function getSequelize(toplevelDir, toplevelBasename) {
  const sequelizeParams = Object.assign(
    {
      logging: config.log.db ? console.log : false,
      // https://stackoverflow.com/questions/52260934/how-to-measure-query-execution-time-in-seqilize
      benchmark: true,
      // https://stackoverflow.com/questions/55715724/how-to-log-queries-with-bounded-paramenters-in-sequelize/70954144#70954144
      logQueryParameters: true,
    },
    ourbigbook_nodejs_webpack_safe.db_options,
  );
  let sequelize;
  if (config.isProduction || config.postgres) {
    sequelizeParams.dialect = config.production.dialect;
    sequelizeParams.dialectOptions = config.production.dialectOptions;
    sequelize = new Sequelize(config.production.url, sequelizeParams);
  } else {
    sequelizeParams.dialect = config.development.dialect;
    let storage;
    if (process.env.NEXT_PUBLIC_NODE_ENV === 'test' || toplevelDir === undefined) {
      storage = ':memory:';
    } else {
      if (toplevelBasename === undefined) {
        toplevelBasename = config.development.storage;
      }
      storage = path.join(toplevelDir, toplevelBasename);
    }
    sequelizeParams.storage = storage;
    sequelize = new Sequelize(sequelizeParams);
  }
  const Article = require('./article')(sequelize)
  const Comment = require('./comment')(sequelize)
  const Issue = require('./issue')(sequelize)
  const SequelizeMeta = require('./sequelize_meta')(sequelize)
  const Site = require('./site')(sequelize)
  const User = require('./user')(sequelize)
  const Topic = require('./topic')(sequelize)
  ourbigbook_models.addModels(sequelize, { web: true })
  const File = sequelize.models.File

  // Associations.

  // User follow user (super many to many)
  const UserFollowUser = sequelize.define('UserFollowUser',
    {
      userId: {
        type: DataTypes.INTEGER,
        references: {
          model: User,
          key: 'id'
        }
      },
      followId: {
        type: DataTypes.INTEGER,
        references: {
          model: User,
          key: 'id'
        }
      },
    },
    {
      tableName: 'UserFollowUser',
      indexes: [
        { fields: ['userId'], },
        { fields: ['followId'], },
        { fields: ['userId', 'followId'], unique: true, },
      ],
    }
  );
  User.belongsToMany(User, { through: UserFollowUser, as: 'follows', foreignKey: 'userId', otherKey: 'followId' });
  // https://stackoverflow.com/questions/27065154/how-to-get-all-children-or-parents-in-a-many-to-many-association-if-one-model-re/72951602#72951602
  User.belongsToMany(User, { through: UserFollowUser, as: 'followed', foreignKey: 'followId', otherKey: 'userId' });
  UserFollowUser.belongsTo(User, { foreignKey: 'userId' })
  User.hasMany(UserFollowUser, { foreignKey: 'followId' })

  // User like Article
  const UserLikeArticle = sequelize.define('UserLikeArticle',
    {
      userId: {
        type: DataTypes.INTEGER,
        references: {
          model: User,
          key: 'id'
        }
      },
      articleId: {
        type: DataTypes.INTEGER,
        references: {
          model: Article,
          key: 'id'
        }
      },
    },
    {
      indexes: [
        { fields: ['userId'], },
        { fields: ['articleId'], },
        { fields: ['userId', 'articleId'], unique: true, },
      ],
    }
  );
  Article.belongsToMany(User, { through: UserLikeArticle, as: 'articleLikedBy', foreignKey: 'articleId', otherKey: 'userId'  })
  User.belongsToMany(Article, { through: UserLikeArticle, as: 'likedArticles',  foreignKey: 'userId', otherKey: 'articleId'  })
  UserLikeArticle.belongsTo(User, { foreignKey: 'userId', as: 'user' })
  UserLikeArticle.belongsTo(Article, { foreignKey: 'articleId', as: 'article' })

  // User follow article.
  // Initial use case: get notifications when new issues are created.
  // One day could be extended to getting notified on any change.
  const UserFollowArticle = sequelize.define('UserFollowArticle',
    {
      userId: {
        type: DataTypes.INTEGER,
        references: {
          model: User,
          key: 'id'
        }
      },
      articleId: {
        type: DataTypes.INTEGER,
        references: {
          model: Article,
          key: 'id'
        }
      },
    },
    {
      tableName: 'UserFollowArticle',
      indexes: [
        { fields: ['userId'], },
        { fields: ['articleId'], },
        { fields: ['userId', 'articleId'], unique: true, },
      ],
    }
  );
  Article.belongsToMany(User, { through: UserFollowArticle, as: 'followers', foreignKey: 'articleId', otherKey: 'userId' })
  User.belongsToMany(Article, { through: UserFollowArticle, as: 'followedArticles', foreignKey: 'userId', otherKey: 'articleId' })

  // User like Issue
  Issue.belongsToMany(User, { through: 'UserLikeIssue', as: 'issueLikedBy', foreignKey: 'issueId', otherKey: 'userId' })
  User.belongsToMany(Issue, { through: 'UserLikeIssue', as: 'likedIssues', foreignKey: 'userId', otherKey: 'issueId' })

  // User follow issue.
  // Initial use case: get notifications when new comments are created under an issue.
  const UserFollowIssue = sequelize.define('UserFollowIssue',
    {
      userId: {
        type: DataTypes.INTEGER,
        references: {
          model: User,
          key: 'id'
        }
      },
      issueId: {
        type: DataTypes.INTEGER,
        references: {
          model: Issue,
          key: 'id'
        }
      },
    },
    {
      tableName: 'UserFollowIssue',
      indexes: [
        { fields: ['userId'], },
        { fields: ['issueId'], },
        { fields: ['userId', 'issueId'], unique: true, },
      ],
    }
  );
  Issue.belongsToMany(User, { through: UserFollowIssue, as: 'followers', foreignKey: 'issueId', otherKey: 'userId'    });
  User.belongsToMany(Issue, { through: UserFollowIssue, as: 'followedIssues', foreignKey: 'userId', otherKey: 'issueId' });

  // Article author User
  File.belongsTo(User, {
    as: 'author',
    foreignKey: {
      name: 'authorId',
      allowNull: false
    }
  })
  User.hasMany(File, {
    as: 'authoredArticles',
    foreignKey: 'authorId'
  })

  // Article belongs to a source File
  Article.belongsTo(File, {
    as: 'file',
    foreignKey: {
      name: 'fileId',
      // TODO https://docs.ourbigbook.com/4
      allowNull: true,
    },
    // TODO https://docs.ourbigbook.com/4
    onDelete: 'SET NULL',
  })
  File.hasMany(Article, {
    // This name is a bad idea as it breaks SQLite case insensitive madness due to conflice with File...
    // https://stackoverflow.com/questions/50926312/how-to-make-column-names-case-sensitive-of-sqlite3-in-python
    // const rows = await File.findAll(
    //   {
    //     include: [
    //       {
    //         model: Article,
    //         as: 'file',
    //       },
    //     ],
    //   }
    // )
    // More complex further nested queries may survive because they have more prefixes that differentiate between them.
    as: 'file',
    foreignKey: 'fileId'
  })

  // Article has Issues
  Article.hasMany(Issue, {
    foreignKey: 'articleId',
    as: 'issues',
    // TODO https://docs.ourbigbook.com/todo/delete-articles
    //onDelete: 'CASCADE',
  })
  Issue.belongsTo(Article, {
    as: 'article',
    foreignKey: {
      name: 'articleId',
      allowNull: false
    },
  })

  // Issue has Comments
  Issue.hasMany(Comment, {
    foreignKey: 'issueId',
    as: 'comments',
    // TODO https://docs.ourbigbook.com/todo/delete-articles
    //onDelete: 'CASCADE',
  })
  Comment.belongsTo(Issue, {
    as: 'issue',
    foreignKey: {
      name: 'issueId',
      allowNull: false
    },
  })

  // User authors Issue
  Issue.belongsTo(User, {
    as: 'author',
    foreignKey: {
      name: 'authorId',
      allowNull: false
    },
  });
  User.hasMany(Issue, { foreignKey: 'authorId' });

  // User authors Comment
  Comment.belongsTo(User, {
    as: 'author',
    foreignKey: {
      name: 'authorId',
      allowNull: false
    },
  });
  User.hasMany(Comment, { foreignKey: 'authorId' });

  Topic.belongsTo(Article, { as: 'article' })
  Article.hasOne(Topic, { as: 'topic', foreignKey: 'articleId', constraints: false })

  Site.belongsTo(Article, { as: 'pinnedArticle', foreignKey: 'pinnedArticleId', allowNull: true })

  //Article.hasMany(Article, { as: 'sameTopic', foreignKey: 'topicId', sourceKey: 'topicId', constraints: false })

  return sequelize;
}

// Do sequelize.sync, and then also populate SequelizeMeta with migrations
// that might not be needed if we've just done a full sync.
async function sync(sequelize, opts={}) {
  let dbExists
  try {
    await sequelize.models.SequelizeMeta.findOne()
    dbExists = true
  } catch(e) {
    if (e instanceof DatabaseError) {
      dbExists = false
    }
  }
  await sequelize.sync(opts)

  // Database triggers.

    const Article = sequelize.models.Article
    const Comment = sequelize.models.Comment
    const File = sequelize.models.File
    const Issue = sequelize.models.Issue
    const User = sequelize.models.User
    const UserLikeArticle = sequelize.models.UserLikeArticle
    const UserFollowArticle = sequelize.models.UserFollowArticle
    const UserLikeIssue = sequelize.models.UserLikeIssue
    const UserFollowIssue = sequelize.models.UserFollowIssue
    const UserFollowUser = sequelize.models.UserFollowUser

    await sequeliezeCreateTriggerUpdateCount(sequelize, Article, UserLikeArticle, 'score', 'articleId')
    await sequeliezeCreateTriggerUpdateCount(sequelize, Article, UserFollowArticle, 'followerCount', 'articleId')
    await sequeliezeCreateTriggerUpdateCount(sequelize, Issue, UserLikeIssue, 'score', 'issueId')
    await sequeliezeCreateTriggerUpdateCount(sequelize, Issue, UserFollowIssue, 'followerCount', 'issueId')
    await sequeliezeCreateTriggerUpdateCount(sequelize, User, UserFollowUser, 'followerCount', 'followId')
    await sequeliezeCreateTriggerUpdateCount(sequelize, Issue, Comment, 'commentCount', 'issueId')
    await sequeliezeCreateTriggerUpdateCount(sequelize, Article, Issue, 'issueCount', 'articleId')

    // Article
    await sequelizeCreateTrigger(sequelize, Article, 'delete',
      `UPDATE "${User.tableName}" SET "score" = "${User.tableName}"."score" - OLD."score"\n` +
      `  FROM "${Article.tableName}", "${File.tableName}" WHERE OLD."fileId" = "File"."id" AND "File"."authorId" = "User"."id"`
      ,
      { after: 'BEFORE', }
    )
    await sequelizeCreateTrigger(
      sequelize,
      Article,
      'update',
      `UPDATE "${User.tableName}" SET "score" = "${User.tableName}"."score" + (NEW."score" - OLD."score")\n` +
      `  FROM "${Article.tableName}", "${File.tableName}" WHERE NEW."fileId" = "File"."id" AND "File"."authorId" = "User"."id"`
      ,
      {
        when: 'OLD."score" <> NEW."score"',
      }
    )
  if (!dbExists || opts.force) {
    await Promise.all([
      sequelize.models.SequelizeMeta.bulkCreate(
        fs.readdirSync(path.join(path.dirname(__dirname), 'migrations')).map(
          basename => { return { name: basename } }
        )
      ),
      sequelize.models.Site.create(),
    ])
  }
  return dbExists
}

/** Optionall check, print and update any of our denormaliezd in-database caches. */
async function normalize({
  check,
  fix,
  log,
  print,
  sequelize,
  usernames,
  transaction,
  whats,
}={}) {
  if (whats.length === 0 || (!check && !fix && !print)) {
    throw new Error(`nothing to be done`)
  }
  if (log === undefined) {
    log = false
  }
  if (usernames === undefined) {
    usernames = []
  }
  const { Article, Comment, Issue, File, User } = sequelize.models
  if (usernames.length === 0) {
    usernames = (await User.findAll({
      attributes: ['username'],
      order: [['username', 'ASC']],
      transaction,
    })).map(u => u.username)
  } else {
    const users = await User.findAll({ where: { username: usernames }})
    const usernameSet = new Set(users.map(u => u.username))
    for (const username of usernames) {
      if (!usernameSet.has(username)) {
        throw new Error(`user does not exist: "${username}"`)
      }
    }
  }
  for (const what of whats) {
    if (log)
      console.log(what);
    for (const username of usernames) {
      if (what === 'nested-set') {
        if (fix) {
          await Article.updateNestedSets(username, { transaction })
        }
        const articles = await Article.treeFindInOrder({ username, transaction })
        if (check) {
          const nestedSetsFromRefs = await Article.getNestedSetsFromRefs(username, { transaction })
          for (let i = 0; i < nestedSetsFromRefs.length; i++) {
            const article = articles[i]
            const fromRef = nestedSetsFromRefs[i]
            const msg = `${what}: (slug, nestedSetIndex, nestedSetNextSibling, depth): actual: (${article.slug}, ${article.nestedSetIndex}, ${article.nestedSetNextSibling}, ${article.depth}) !== expected: (${fromRef.id}, ${fromRef.nestedSetIndex}, ${fromRef.nestedSetNextSibling}, ${fromRef.depth})`
            assert.strictEqual(article.nestedSetIndex, fromRef.nestedSetIndex, msg)
            assert.strictEqual(article.nestedSetNextSibling, fromRef.nestedSetNextSibling, msg)
            assert.strictEqual(article.depth, fromRef.depth, msg)
            assert.strictEqual(`@${article.slug}`, fromRef.id, msg)
          }
        }
        if (print) {
          throw new Error('-p is broken for nested-set, does not show new would-be updated value as desired');
          for (const article of articles) {
            console.log(`${what} ${article.nestedSetIndex} ${article.nestedSetNextSibling} ${article.slug}`)
          }
        }
      } else if (
        what === 'article-issue-count' ||
        what === 'article-follower-count' ||
        what === 'issue-comment-count' ||
        what === 'issue-follower-count'
      ) {
        let parentModel, childModel, as, emptyThrough
        if (what === 'article-issue-count') {
          parentModel = Article
          childModel = Issue
          as = 'issues'
          checkField = 'issueCount'
          emptyThrough = false
        } else if(what === 'article-follower-count') {
          parentModel = Article
          childModel = User
          as = 'followers'
          checkField = 'followerCount'
          emptyThrough = true
        } else if (what === 'issue-comment-count') {
          parentModel = Issue
          childModel = Comment
          as = 'comments'
          checkField = 'commentCount'
          emptyThrough = false
        } else if (what === 'issue-follower-count') {
          parentModel = Issue
          childModel = User
          as = 'followers'
          checkField = 'followerCount'
          emptyThrough = true
        }
        const includeChild = {
          model: childModel,
          as,
          required: false,
          attributes: [],
        }
        if (emptyThrough) {
          // OMG sequelize
          includeChild.through = { attributes: [] }
        }
        const include = [
          includeChild,
        ]
        if (parentModel === Issue) {
          // Ideally, but PostgreSQL won't let us due to GROUP BY.
          //include.push({
          //  model: Article,
          //  as: 'article',
          //  attributes: ['slug'],
          //})
          slugAttr = 'number'
          include.push({
            model: User,
            as: 'author',
            where: { username },
            required: true,
            attributes: [],
          })
        } else {
          slugAttr = 'slug'
          include.push({
            model: File,
            as: 'file',
            attributes: [],
            subQuery: false,
            required: true,
            include: {
              model: User,
              as: 'author',
              where: { username },
              required: true,
              attributes: [],
            }
          })
        }
        const counts = await parentModel.findAll({
          attributes: [
            'id',
            slugAttr,
            [checkField, 'checkField'],
            [sequelize.fn('COUNT', sequelize.col(`${as}.id`)), 'count'],
          ],
          subQuery: false,
          include,
          group: [`${parentModel.name}.id`],
          order: [['id', 'ASC']],
          transaction,
        })
        if (parentModel === Issue) {
          const countsArticle = await parentModel.findAll({
            include: [
              {
                model: Article,
                as: 'article',
                attributes: ['slug'],
              },
              {
                model: User,
                as: 'author',
                where: { username },
                required: true,
                attributes: [],
              }
            ],
            order: [['id', 'ASC']],
            transaction,
          })
          for (let i = 0; i < countsArticle.length; i++) {
            counts[i].article = countsArticle[i].article
          }
        }
        for (const count of counts) {
          count.countInt = parseInt(count.get('count'), 10)
        }
        if (check) {
          for (const count of counts) {
            const msg = `${what} ${count.getSlug()} ${count.countInt} !== ${count.get('checkField')}`
            assert.strictEqual(count.countInt, count.get('checkField'), msg)
          }
        }
        if (print) {
          for (const count of counts) {
            console.log(`${what} ${count.getSlug()} ${count.get('checkField')}`);
          }
        }
        if (fix) {
          for (const count of counts) {
            if (log)
              console.log(`${what} ${count.getSlug()} ${count.countInt}`);
            await Promise.all([
              parentModel.update(
                { [checkField]: count.countInt },
                {
                  // Oopsie I did nuke timestamps once because of this O_O
                  silent: true,
                  transaction,
                  where: { id: count.id }
                }
              )
            ])
          }
        }
      } else if (
        // Not a normalization.
        what === 'follow-authored-articles'
      ) {
        const [articles, user] = await Promise.all([
          Article.getArticles({
            author: username,
            count: false,
            sequelize,
            transaction,
          }),
          User.findOne({
            where: { username },
            transaction,
          }),
        ])
        if (fix) {
          const promises = []
          for (const article of articles) {
            if (log)
              console.log(`${what} ${username} ${article.getSlug()}`);
            promises.push(user.addArticleFollowSideEffects(article, { transaction }))
          }
          await Promise.all(promises)
        }
      } else if (
        // Not a normalization.
        what === 'follow-authored-issues'
      ) {
        const [{ rows: issues }, user] = await Promise.all([
          Issue.getIssues({
            author: username,
            includeArticle: true,
            sequelize,
            transaction,
          }),
          User.findOne({
            where: { username },
            transaction,
          }),
        ])
        if (fix) {
          const promises = []
          for (const issue of issues) {
            if (log)
              console.log(`${what} ${username} ${issue.getSlug()}`);
            promises.push(user.addIssueFollowSideEffects(issue, { transaction }))
          }
          await Promise.all(promises)
        }
      } else if (
        what === 'file-has-article'
      ) {
        // Check that all files have articles. This could fail notably due to a bug in the complex synonym renaming mechanism.
        // TODO known to not work on SQLite due to case insensitive, se need to change the as: 'file" to as: 'article' in the join..
        const rows = await File.findAll({
          attributes: [
            'id',
            'path',
            [sequelize.fn('COUNT', sequelize.col('file.id')), 'count'],
          ],
          include: [
            {
              model: Article,
              as: 'file',
              required: false,
              attributes: [],
            },
            {
              model: User,
              as: 'author',
              required: true,
              attributes: [],
              where: { username },
            },
          ],
          group: ['File.id'],
          order: [[sequelize.col('count'), 'DESC']],
          having: sequelize.where(sequelize.fn('COUNT', sequelize.col('file.id')), 0)
        })
        for (const row of rows) {
          console.error(row.path)
          if (fix)
            await row.destroy()
        }
        if (check && rows.length)
          throw new Error(`there were files without a corresponding article`)
      } else if (
        what === 'topic-count' ||
        what === 'user-follower-count'
      ) {
        throw new Error(`unimplemented: ${what}`)
      } else {
        throw new Error(`unknown what: ${what}`)
      }
    }
  }
}

module.exports = {
  getSequelize,
  normalize,
  sync,
}