From a7b00294713c6e0f51d2f77ce24d38df900cf021 Mon Sep 17 00:00:00 2001 From: Katharina Irrgang Date: Tue, 9 Oct 2018 15:31:09 +0200 Subject: [PATCH] Added mobiledoc revisions functionality closes #9927 - Added post model implementation to be able to store up to 10 versions of mobiledoc - Bumped GQL to support filtering on the mobiledoc revision table - Added tests ensuring new functionality works --- core/server/models/base/index.js | 8 +- core/server/models/index.js | 3 +- core/server/models/mobiledoc-revision.js | 35 ++++++ core/server/models/post.js | 83 ++++++++++--- core/test/integration/migration_spec.js | 3 +- .../integration/model/model_posts_spec.js | 113 ++++++++++++++++++ core/test/unit/models/post_spec.js | 52 +++++--- package.json | 2 +- yarn.lock | 6 +- 9 files changed, 267 insertions(+), 38 deletions(-) create mode 100644 core/server/models/mobiledoc-revision.js diff --git a/core/server/models/base/index.js b/core/server/models/base/index.js index 04664b0703..082626b8d3 100644 --- a/core/server/models/base/index.js +++ b/core/server/models/base/index.js @@ -647,8 +647,14 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({ this.processOptions(options); } - itemCollection.applyDefaultAndCustomFilters(options); + // @TODO: we can't use order raw when running migrations (see https://github.com/tgriesser/knex/issues/2763) + if (this.orderDefaultRaw && !options.migrating) { + itemCollection.query((qb) => { + qb.orderByRaw(this.orderDefaultRaw()); + }); + } + itemCollection.applyDefaultAndCustomFilters(options); return itemCollection.fetchAll(options).then(function then(result) { if (options.withRelated) { _.each(result.models, function each(item) { diff --git a/core/server/models/index.js b/core/server/models/index.js index d9d71fa5b5..eabedbaca9 100644 --- a/core/server/models/index.js +++ b/core/server/models/index.js @@ -33,7 +33,8 @@ models = [ 'invite', 'webhook', 'integration', - 'api-key' + 'api-key', + 'mobiledoc-revision' ]; function init() { diff --git a/core/server/models/mobiledoc-revision.js b/core/server/models/mobiledoc-revision.js new file mode 100644 index 0000000000..db99b0530f --- /dev/null +++ b/core/server/models/mobiledoc-revision.js @@ -0,0 +1,35 @@ +const ghostBookshelf = require('./base'); + +const MobiledocRevision = ghostBookshelf.Model.extend({ + tableName: 'mobiledoc_revisions' +}, { + permittedOptions(methodName) { + let options = ghostBookshelf.Model.permittedOptions.call(this, methodName); + const validOptions = { + findAll: ['filter', 'columns'] + }; + + if (validOptions[methodName]) { + options = options.concat(validOptions[methodName]); + } + + return options; + }, + + orderDefaultRaw() { + return 'created_at_ts DESC'; + }, + + toJSON(unfilteredOptions) { + const options = MobiledocRevision.filterOptions(unfilteredOptions, 'toJSON'); + const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options); + + // CASE: only for internal accuracy + delete attrs.created_at_ts; + return attrs; + } +}); + +module.exports = { + MobiledocRevision: ghostBookshelf.model('MobiledocRevision', MobiledocRevision) +}; diff --git a/core/server/models/post.js b/core/server/models/post.js index df9683c48b..e58a4f728d 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -1,17 +1,19 @@ // # Post Model -var _ = require('lodash'), - uuid = require('uuid'), - moment = require('moment'), - Promise = require('bluebird'), - sequence = require('../lib/promise/sequence'), - common = require('../lib/common'), - htmlToText = require('html-to-text'), - ghostBookshelf = require('./base'), - config = require('../config'), - converters = require('../lib/mobiledoc/converters'), - relations = require('./relations'), - Post, - Posts; +const _ = require('lodash'); +const uuid = require('uuid'); +const moment = require('moment'); +const Promise = require('bluebird'); +const sequence = require('../lib/promise/sequence'); +const common = require('../lib/common'); +const htmlToText = require('html-to-text'); +const ghostBookshelf = require('./base'); +const config = require('../config'); +const converters = require('../lib/mobiledoc/converters'); +const relations = require('./relations'); +const MOBILEDOC_REVISIONS_COUNT = 10; + +let Post; +let Posts; Post = ghostBookshelf.Model.extend({ @@ -46,7 +48,7 @@ Post = ghostBookshelf.Model.extend({ }; }, - relationships: ['tags', 'authors'], + relationships: ['tags', 'authors', 'mobiledoc_revisions'], // NOTE: look up object, not super nice, but was easy to implement relationshipBelongsTo: { @@ -348,6 +350,51 @@ Post = ghostBookshelf.Model.extend({ }); } + // CASE: Handle mobiledoc backups/revisions. This is a pure database feature. + if (model.hasChanged('mobiledoc') && !options.importing && !options.migrating) { + ops.push(function updateRevisions() { + return ghostBookshelf.model('MobiledocRevision') + .findAll(Object.assign({ + filter: `post_id:${model.id}`, + columns: ['id'] + }, _.pick(options, 'transacting'))) + .then((revisions) => { + /** + * Store prev + latest mobiledoc content, because we have decided against a migration, which + * iterates over all posts and creates a copy of the current mobiledoc content. + * + * Reasons: + * - usually migrations for the post table are slow and error-prone + * - there is no need to create a copy for all posts now, because we only want to ensure + * that posts, which you are currently working on, are getting a content backup + * - no need to create revisions for existing published posts + * + * The feature is very minimal in the beginning. As soon as you update to this Ghost version, + * you + */ + if (!revisions.length && options.method !== 'insert') { + model.set('mobiledoc_revisions', [{ + post_id: model.id, + mobiledoc: model.previous('mobiledoc'), + created_at_ts: Date.now() - 1 + }, { + post_id: model.id, + mobiledoc: model.get('mobiledoc'), + created_at_ts: Date.now() + }]); + } else { + const revisionsJSON = revisions.toJSON().slice(0, MOBILEDOC_REVISIONS_COUNT - 1); + + model.set('mobiledoc_revisions', revisionsJSON.concat([{ + post_id: model.id, + mobiledoc: model.get('mobiledoc'), + created_at_ts: Date.now() + }])); + } + }); + }); + } + return sequence(ops); }, @@ -385,6 +432,11 @@ Post = ghostBookshelf.Model.extend({ fields: function fields() { return this.morphMany('AppField', 'relatable'); }, + + mobiledoc_revisions() { + return this.hasMany('MobiledocRevision', 'post_id'); + }, + /** * @NOTE: * If you are requesting models with `columns`, you try to only receive some fields of the model/s. @@ -441,6 +493,9 @@ Post = ghostBookshelf.Model.extend({ attrs = this.formatsToJSON(attrs, options); + // CASE: never expose the revisions + delete attrs.mobiledoc_revisions; + // If the current column settings allow it... if (!options.columns || (options.columns && options.columns.indexOf('primary_tag') > -1)) { // ... attach a computed property of primary_tag which is the first tag if it is public, else null diff --git a/core/test/integration/migration_spec.js b/core/test/integration/migration_spec.js index 928d2b56dc..15a22b5521 100644 --- a/core/test/integration/migration_spec.js +++ b/core/test/integration/migration_spec.js @@ -218,7 +218,8 @@ describe('Database Migration (special functions)', function () { // Post should.exist(result.posts); result.posts.length.should.eql(7); - result.posts.at(0).get('title').should.eql('Creating a custom theme'); + result.posts.at(0).get('title').should.eql('Welcome to Ghost'); + result.posts.at(6).get('title').should.eql('Creating a custom theme'); // Tag should.exist(result.tags); diff --git a/core/test/integration/model/model_posts_spec.js b/core/test/integration/model/model_posts_spec.js index f9ec544848..ca64d0e805 100644 --- a/core/test/integration/model/model_posts_spec.js +++ b/core/test/integration/model/model_posts_spec.js @@ -1683,6 +1683,119 @@ describe('Post Model', function () { }); }); + describe('mobiledoc versioning', function () { + it('can create revisions', function () { + const newPost = { + mobiledoc: markdownToMobiledoc('a') + }; + + return models.Post.add(newPost, context) + .then((createdPost) => { + return models.Post.findOne({id: createdPost.id, status: 'all'}); + }) + .then((createdPost) => { + should.exist(createdPost); + + return createdPost.save({mobiledoc: markdownToMobiledoc('b')}, context); + }) + .then((updatedPost) => { + updatedPost.get('mobiledoc').should.equal(markdownToMobiledoc('b')); + + return models.MobiledocRevision + .findAll({ + filter: `post_id:${updatedPost.id}`, + }); + }) + .then((mobiledocRevisions) => { + should.equal(mobiledocRevisions.length, 2); + + mobiledocRevisions.toJSON()[0].mobiledoc.should.equal(markdownToMobiledoc('b')); + mobiledocRevisions.toJSON()[1].mobiledoc.should.equal(markdownToMobiledoc('a')); + }); + }); + + it('keeps only 10 last revisions in FIFO style', function () { + let revisionedPost; + const newPost = { + mobiledoc: markdownToMobiledoc('revision: 0') + }; + + return models.Post.add(newPost, context) + .then((createdPost) => { + return models.Post.findOne({id: createdPost.id, status: 'all'}); + }) + .then((createdPost) => { + should.exist(createdPost); + revisionedPost = createdPost; + + return sequence(_.times(11, (i) => { + return () => { + return models.Post.edit({ + mobiledoc: markdownToMobiledoc('revision: ' + (i + 1)) + }, _.extend({}, context, {id: createdPost.id})); + }; + })); + }) + .then(() => models.MobiledocRevision + .findAll({ + filter: `post_id:${revisionedPost.id}`, + }) + ) + .then((mobiledocRevisions) => { + should.equal(mobiledocRevisions.length, 10); + + mobiledocRevisions.toJSON()[0].mobiledoc.should.equal(markdownToMobiledoc('revision: 11')); + mobiledocRevisions.toJSON()[9].mobiledoc.should.equal(markdownToMobiledoc('revision: 2')); + }); + }); + + it('creates 2 revisions after first edit for previously unversioned post', function () { + let unversionedPost; + + const newPost = { + title: 'post title', + mobiledoc: markdownToMobiledoc('a') + }; + + // passing 'migrating' flag to simulate unversioned post + const options = Object.assign(_.clone(context), {migrating: true}); + + return models.Post.add(newPost, options) + .then((createdPost) => { + should.exist(createdPost); + unversionedPost = createdPost; + createdPost.get('mobiledoc').should.equal(markdownToMobiledoc('a')); + + return models.MobiledocRevision + .findAll({ + filter: `post_id:${createdPost.id}`, + }); + }) + .then((mobiledocRevisions) => { + should.equal(mobiledocRevisions.length, 0); + + return models.Post.edit({ + mobiledoc: markdownToMobiledoc('b') + }, _.extend({}, context, {id: unversionedPost.id})); + }) + .then((editedPost) => { + should.exist(editedPost); + editedPost.get('mobiledoc').should.equal(markdownToMobiledoc('b')); + + return models.MobiledocRevision + .findAll({ + filter: `post_id:${editedPost.id}`, + }); + }) + .then((mobiledocRevisions) => { + should.equal(mobiledocRevisions.length, 2); + + mobiledocRevisions.toJSON()[0].mobiledoc.should.equal(markdownToMobiledoc('b')); + mobiledocRevisions.toJSON()[1].mobiledoc.should.equal(markdownToMobiledoc('a')); + }); + }); + }); + describe('Multiauthor Posts', function () { before(testUtils.teardown); diff --git a/core/test/unit/models/post_spec.js b/core/test/unit/models/post_spec.js index c5e8528ad8..8d813fa4fe 100644 --- a/core/test/unit/models/post_spec.js +++ b/core/test/unit/models/post_spec.js @@ -253,27 +253,23 @@ describe('Unit: models/post', function () { }); }); }); -}); -describe('Unit: models/post: uses database (@TODO: fix me)', function () { - before(function () { - models.init(); - }); + describe('toJSON', function () { + const toJSON = function toJSON(model, options) { + return new models.Post(model).toJSON(options); + }; - before(testUtils.teardown); - before(testUtils.setup('users:roles', 'posts')); + it('ensure mobiledoc revisions are never exposed', function () { + const post = { + mobiledoc: 'test', + mobiledoc_revisions: [], + }; - beforeEach(function () { - sandbox.stub(security.password, 'hash').resolves('$2a$10$we16f8rpbrFZ34xWj0/ZC.LTPUux8ler7bcdTs5qIleN6srRHhilG'); - sandbox.stub(urlService, 'getUrlByResourceId'); - }); + const json = toJSON(post, {formats: ['mobiledoc']}); - afterEach(function () { - sandbox.restore(); - }); - - after(function () { - sandbox.restore(); + should.not.exist(json.mobiledoc_revisions); + should.exist(json.mobiledoc); + }); }); describe('processOptions', function () { @@ -364,6 +360,28 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { filter.should.equal('page:false+status:published'); }); }); +}); + +describe('Unit: models/post: uses database (@TODO: fix me)', function () { + before(function () { + models.init(); + }); + + before(testUtils.teardown); + before(testUtils.setup('users:roles', 'posts')); + + beforeEach(function () { + sandbox.stub(security.password, 'hash').resolves('$2a$10$we16f8rpbrFZ34xWj0/ZC.LTPUux8ler7bcdTs5qIleN6srRHhilG'); + sandbox.stub(urlService, 'getUrlByResourceId'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + after(function () { + sandbox.restore(); + }); describe('add', function () { describe('ensure full set of data for model events', function () { diff --git a/package.json b/package.json index 426865f2a9..64a1d807b8 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "express-session": "1.15.6", "extract-zip": "1.6.7", "fs-extra": "3.0.1", - "ghost-gql": "0.0.10", + "ghost-gql": "0.0.11", "ghost-ignition": "2.9.6", "ghost-storage-base": "0.0.3", "glob": "5.0.15", diff --git a/yarn.lock b/yarn.lock index c1f089255b..17fa1c8369 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2200,9 +2200,9 @@ getsetdeep@~2.0.0: dependencies: typechecker "~2.0.1" -ghost-gql@0.0.10: - version "0.0.10" - resolved "https://registry.yarnpkg.com/ghost-gql/-/ghost-gql-0.0.10.tgz#cd546ed77ee8f135a520d5d0463bbe4f01c24a10" +ghost-gql@0.0.11: + version "0.0.11" + resolved "https://registry.yarnpkg.com/ghost-gql/-/ghost-gql-0.0.11.tgz#f0bc85305d0be80e5131011bf48b0ffd7c058b6b" dependencies: lodash "^4.17.4"