From 0bfe99af72bafc4946cfaf0fcac59c8056142372 Mon Sep 17 00:00:00 2001 From: Jason Williams Date: Sun, 20 Jul 2014 21:48:24 +0000 Subject: [PATCH] Extend adapter to support automatic includes Closes #3325 - Add Roles model and add hasMany roles to User model. - Add EmbeddedRelationAdapter that will automatically include hasMany relations in calls to the API. - UserAdapter and PostAdapter now extend EmbeddedRelationAdapter and all explicit includes from store.find() have been removed. --- ghost/admin/adapters/application.js | 2 - .../adapters/embedded-relation-adapter.js | 70 +++++++++++++++++++ ghost/admin/adapters/post.js | 4 +- ghost/admin/adapters/user.js | 38 ++++++++++ ghost/admin/models/role.js | 15 ++++ ghost/admin/models/user.js | 1 + ghost/admin/routes/editor/edit.js | 1 - ghost/admin/routes/posts.js | 1 - ghost/admin/routes/posts/index.js | 1 - ghost/admin/routes/posts/post.js | 1 - ghost/admin/serializers/user.js | 34 +++++++++ 11 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 ghost/admin/adapters/embedded-relation-adapter.js create mode 100644 ghost/admin/adapters/user.js create mode 100644 ghost/admin/models/role.js create mode 100644 ghost/admin/serializers/user.js diff --git a/ghost/admin/adapters/application.js b/ghost/admin/adapters/application.js index b0766882e4..5b99bcafa5 100644 --- a/ghost/admin/adapters/application.js +++ b/ghost/admin/adapters/application.js @@ -1,7 +1,5 @@ import ghostPaths from 'ghost/utils/ghost-paths'; -// export default DS.FixtureAdapter.extend({}); - var ApplicationAdapter = DS.RESTAdapter.extend({ host: window.location.origin, namespace: ghostPaths().apiRoot.slice(1), diff --git a/ghost/admin/adapters/embedded-relation-adapter.js b/ghost/admin/adapters/embedded-relation-adapter.js new file mode 100644 index 0000000000..fbaa15dc67 --- /dev/null +++ b/ghost/admin/adapters/embedded-relation-adapter.js @@ -0,0 +1,70 @@ +import ApplicationAdapter from 'ghost/adapters/application'; + +// EmbeddedRelationAdapter will augment the query object in calls made to +// DS.Store#find, findQuery, and findAll with the correct "includes" +// (?include=relatedType) by introspecting on the provided subclass of the DS.Model. +// +// Example: +// If a model has an embedded hasMany relation, the related type will be included: +// roles: DS.hasMany('role', { embedded: 'always' }) => ?include=roles + +var EmbeddedRelationAdapter = ApplicationAdapter.extend({ + find: function (store, type, id) { + return this.findQuery(store, type, this.buildQuery(store, type, id)); + }, + + findQuery: function (store, type, query) { + return this._super(store, type, this.buildQuery(store, type, query)); + }, + + findAll: function (store, type, sinceToken) { + return this.findQuery(store, type, this.buildQuery(store, type, sinceToken)); + }, + + buildQuery: function (store, type, options) { + var model, + toInclude = [], + query = {}, + deDupe = {}; + + // Get the class responsible for creating records of this type + model = store.modelFor(type); + + // Iterate through the model's relationships and build a list + // of those that need to be pulled in via "include" from the API + model.eachRelationship(function (name, meta) { + if (meta.kind === 'hasMany' && + Object.prototype.hasOwnProperty.call(meta.options, 'embedded') && + meta.options.embedded === 'always') { + + toInclude.push(name); + } + }); + + if (toInclude.length) { + // If this is a find by id, build a query object and attach the includes + if (typeof options === 'string' || typeof options === 'number') { + query.id = options; + query.include = toInclude.join(','); + } + // If this is a find all (no existing query object) build one and attach + // the includes. + // If this is a find with an existing query object then merge the includes + // into the existing object. Existing properties and includes are preserved. + else if (typeof options === 'object' || Ember.isNone(options)) { + query = options || query; + toInclude = toInclude.concat(query.include ? query.include.split(',') : []); + + toInclude.forEach(function (include) { + deDupe[include] = true; + }); + + query.include = Object.keys(deDupe).join(','); + } + } + + return query; + } +}); + +export default EmbeddedRelationAdapter; diff --git a/ghost/admin/adapters/post.js b/ghost/admin/adapters/post.js index a491a42c6c..ede06a1837 100644 --- a/ghost/admin/adapters/post.js +++ b/ghost/admin/adapters/post.js @@ -1,6 +1,6 @@ -import ApplicationAdapter from 'ghost/adapters/application'; +import EmbeddedRelationAdapter from 'ghost/adapters/embedded-relation-adapter'; -var PostAdapter = ApplicationAdapter.extend({ +var PostAdapter = EmbeddedRelationAdapter.extend({ createRecord: function (store, type, record) { var data = {}, serializer = store.serializerFor(type.typeKey), diff --git a/ghost/admin/adapters/user.js b/ghost/admin/adapters/user.js new file mode 100644 index 0000000000..6d75617f67 --- /dev/null +++ b/ghost/admin/adapters/user.js @@ -0,0 +1,38 @@ +import EmbeddedRelationAdapter from 'ghost/adapters/embedded-relation-adapter'; + +var UserAdapter = EmbeddedRelationAdapter.extend({ + createRecord: function (store, type, record) { + var data = {}, + serializer = store.serializerFor(type.typeKey), + url = this.buildURL(type.typeKey); + + // Ask the API to include full role objects in its response + url += '?include=roles'; + + // Use the UserSerializer to transform the model back into + // an array of user objects like the API expects + serializer.serializeIntoHash(data, type, record); + + // Use the url from the ApplicationAdapter's buildURL method + return this.ajax(url, 'POST', { data: data }); + }, + + updateRecord: function (store, type, record) { + var data = {}, + serializer = store.serializerFor(type.typeKey), + id = Ember.get(record, 'id'), + url = this.buildURL(type.typeKey, id); + + // Ask the API to include full role objects in its response + url += '?include=roles'; + + // Use the UserSerializer to transform the model back into + // an array of user objects like the API expects + serializer.serializeIntoHash(data, type, record); + + // Use the url from the ApplicationAdapter's buildURL method + return this.ajax(url, 'PUT', { data: data }); + } +}); + +export default UserAdapter; diff --git a/ghost/admin/models/role.js b/ghost/admin/models/role.js new file mode 100644 index 0000000000..bcff3d202d --- /dev/null +++ b/ghost/admin/models/role.js @@ -0,0 +1,15 @@ +var Role = DS.Model.extend({ + uuid: DS.attr('string'), + name: DS.attr('string'), + description: DS.attr('string'), + created_at: DS.attr('moment-date'), + created_by: DS.belongsTo('user', { async: true }), + updated_at: DS.attr('moment-date'), + updated_by: DS.belongsTo('user', { async: true }), + + lowerCaseName: Ember.computed('name', function () { + return this.get('name').toLocaleLowerCase(); + }) +}); + +export default Role; diff --git a/ghost/admin/models/user.js b/ghost/admin/models/user.js index 75595683f4..9e03e36bd5 100644 --- a/ghost/admin/models/user.js +++ b/ghost/admin/models/user.js @@ -23,6 +23,7 @@ var User = DS.Model.extend(NProgressSaveMixin, ValidationEngine, { created_by: DS.belongsTo('user', { async: true }), updated_at: DS.attr('moment-date'), updated_by: DS.belongsTo('user', { async: true }), + roles: DS.hasMany('role', { embedded: 'always' }), saveNewPassword: function () { var url = this.get('ghostPaths.url').api('users', 'password'); diff --git a/ghost/admin/routes/editor/edit.js b/ghost/admin/routes/editor/edit.js index b0b2e8d684..d1b5e911a4 100644 --- a/ghost/admin/routes/editor/edit.js +++ b/ghost/admin/routes/editor/edit.js @@ -24,7 +24,6 @@ var EditorEditRoute = Ember.Route.extend(Ember.SimpleAuth.AuthenticatedRouteMixi id: params.post_id, status: 'all', staticPages: 'all', - include: 'tags' }).then(function (records) { var post = records.get('firstObject'); diff --git a/ghost/admin/routes/posts.js b/ghost/admin/routes/posts.js index 179ea9a85c..8f789eef98 100644 --- a/ghost/admin/routes/posts.js +++ b/ghost/admin/routes/posts.js @@ -5,7 +5,6 @@ import loadingIndicator from 'ghost/mixins/loading-indicator'; var paginationSettings = { status: 'all', staticPages: 'all', - include: 'tags', page: 1 }; diff --git a/ghost/admin/routes/posts/index.js b/ghost/admin/routes/posts/index.js index 996297d931..52f46cbe60 100644 --- a/ghost/admin/routes/posts/index.js +++ b/ghost/admin/routes/posts/index.js @@ -8,7 +8,6 @@ var PostsIndexRoute = Ember.Route.extend(Ember.SimpleAuth.AuthenticatedRouteMixi return this.store.find('post', { status: 'all', staticPages: 'all', - include: 'tags' }).then(function (records) { var post = records.get('firstObject'); diff --git a/ghost/admin/routes/posts/post.js b/ghost/admin/routes/posts/post.js index 29a46907b7..7d372340ad 100644 --- a/ghost/admin/routes/posts/post.js +++ b/ghost/admin/routes/posts/post.js @@ -24,7 +24,6 @@ var PostsPostRoute = Ember.Route.extend(Ember.SimpleAuth.AuthenticatedRouteMixin id: params.post_id, status: 'all', staticPages: 'all', - include: 'tags' }).then(function (records) { var post = records.get('firstObject'); diff --git a/ghost/admin/serializers/user.js b/ghost/admin/serializers/user.js new file mode 100644 index 0000000000..eb9e84e4fd --- /dev/null +++ b/ghost/admin/serializers/user.js @@ -0,0 +1,34 @@ +import ApplicationSerializer from 'ghost/serializers/application'; + +var UserSerializer = ApplicationSerializer.extend(DS.EmbeddedRecordsMixin, { + attrs: { + roles: { embedded: 'always' } + }, + + extractSingle: function (store, primaryType, payload) { + var root = this.keyForAttribute(primaryType.typeKey), + pluralizedRoot = Ember.String.pluralize(primaryType.typeKey); + + payload[root] = payload[pluralizedRoot][0]; + delete payload[pluralizedRoot]; + + return this._super.apply(this, arguments); + }, + + keyForAttribute: function (attr) { + return attr; + }, + + keyForRelationship: function (relationshipName) { + // this is a hack to prevent Ember-Data from deleting our `tags` reference. + // ref: https://github.com/emberjs/data/issues/2051 + // @TODO: remove this once the situation becomes clearer what to do. + if (relationshipName === 'roles') { + return 'role'; + } + + return relationshipName; + } +}); + +export default UserSerializer;