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
This commit is contained in:
Katharina Irrgang 2018-10-09 15:31:09 +02:00 committed by Nazar Gargol
parent 1b9aa2546f
commit a7b0029471
9 changed files with 267 additions and 38 deletions

View File

@ -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) {

View File

@ -33,7 +33,8 @@ models = [
'invite',
'webhook',
'integration',
'api-key'
'api-key',
'mobiledoc-revision'
];
function init() {

View File

@ -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)
};

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -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 () {

View File

@ -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",

View File

@ -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"