Repository: robsontenorio/vue-api-query
Pull request #127 (2020-10-10 15:40:51)

feat(model): add support to nested keys for relations
As requested by @francoism90, this PR add support for nested keys e.g. `relationships.tags`

### Usage
You can apply a model instance to a nested object by setting the key and the model in `relations` method.

If the backend responds with:
```js
// response from API for /posts/1
{
  title: 'My title'
  body: 'Some text here',
  relationships: {
    tags: [
      {
        name: 'super'
      },
      {
        name: 'awesome'
      }
    ]
  }
}
```

We just need to set `relationships.tags` to Tag model:


**/models/Post.js**
```js
class Post extends Model {
  relations () {
    return {
      // Apply Tag model to each object of `relationships.tags` array
      'relationships.tags': Tag
    }
  }
}
```

francoism90: @JoaoPedroAS51 Many thanks 👍
robsontenorio: @JoaoPedroAS51 muito bom! Você poderia me mandar um DM @robsontenorio no Twitter?
JoaoPedroAS51: @robsontenorio Eu não uso o Twitter. Pode ser Discord? Joao Pedro AS51#1284
francoism90: @robsontenorio Sorry for asking, but is this going to be merged/planned to be supported? :)
JoaoPedroAS51: @francoism90 Yes, it will be merged soon. :)

Patch:
diff --git a/README.md b/README.md
index 2f3bce3..2e6c4a2 100644
--- a/README.md
+++ b/README.md
@@ -517,7 +517,7 @@ await this.posts.comments().attach(payload)
 await this.posts.comments().sync(payload) 
 ```
 
-You can also apply a model instance to a nested object by setting the key and the model in `relations` method.
+You can also apply a model instance to a nested object by setting the key and the model in `relations` method. It supports nested keys.
 
 If the backend responds with:
 
@@ -529,11 +529,16 @@ If the backend responds with:
   user: {
     firstName: 'John',
     lastName: 'Doe'
+  },
+  relationships: {
+    tag: {
+      name: 'awesome'
+    }
   }
 }
 ```
 
-We just need to set `user` to User model:
+We just need to set `user` to User model and `relationships.tag`to Tag model:
 
 
 **/models/Post.js**
@@ -542,7 +547,9 @@ class Post extends Model {
   relations () {
     return {
       // Apply User model to `user` object
-      user: User
+      user: User,
+      // Apply Tag model to `relationships.tag` object
+      'relationships.tag': Tag
     }
   }
 }
diff --git a/src/Model.js b/src/Model.js
index 8ac1ad2..b4da026 100644
--- a/src/Model.js
+++ b/src/Model.js
@@ -1,5 +1,6 @@
 import Builder from './Builder';
 import StaticModel from './StaticModel';
+import { getProp, setProp } from './utils'
 
 export default class Model extends StaticModel {
 
@@ -138,7 +139,7 @@ export default class Model extends StaticModel {
   }
 
   isValidId(id) {
-    return id !== undefined && id !== 0 && id !== ''
+    return id !== undefined && id !== 0 && id !== '' && id !== null
   }
 
   endpoint() {
@@ -255,20 +256,22 @@ export default class Model extends StaticModel {
     const relations = model.relations()
 
     for(const relation of Object.keys(relations)) {
-      if (!model[relation]) {
+      const _relation = getProp(model, relation)
+
+      if (!_relation) {
         return;
       }
 
-      if (Array.isArray(model[relation].data) || Array.isArray(model[relation])) {
-        const collection = this._applyInstanceCollection(model[relation], relations[relation])
+      if (Array.isArray(_relation.data) || Array.isArray(_relation)) {
+        const collection = this._applyInstanceCollection(_relation, relations[relation])
 
-        if (model[relation].data !== undefined) {
-          model[relation].data = collection
+        if (_relation.data !== undefined) {
+          _relation.data = collection
         } else {
-          model[relation] = collection
+          setProp(model, relation, collection)
         }
       } else {
-        model[relation] = this._applyInstance(model[relation], relations[relation])
+        setProp(model, relation, this._applyInstance(_relation, relations[relation]))
       }
     }
   }
@@ -370,7 +373,7 @@ export default class Model extends StaticModel {
       url: this.endpoint(),
       data: this
     }).then(response => {
-      return this._applyInstance(response.data)
+      return this._applyInstance(response.data.data || response.data)
     })
   }
 
@@ -380,7 +383,7 @@ export default class Model extends StaticModel {
       url: this.endpoint(),
       data: this
     }).then(response => {
-      return this._applyInstance(response.data)
+      return this._applyInstance(response.data.data || response.data)
     })
   }
 
diff --git a/src/utils.js b/src/utils.js
new file mode 100644
index 0000000..46b598a
--- /dev/null
+++ b/src/utils.js
@@ -0,0 +1,44 @@
+/**
+ * Get property defined by dot notation in string.
+ * Based on  https://github.com/dy/dotprop (MIT)
+ *
+ * @param  {Object} holder   Target object where to look property up
+ * @param  {string} propName Dot notation, like 'this.a.b.c'
+ * @return {*}          A property value
+ */
+export function getProp (holder, propName) {
+  if (!propName || !holder) {
+    return holder
+  }
+
+  if (propName in holder) {
+    return holder[propName]
+  }
+
+  const propParts = Array.isArray(propName) ? propName : (propName + '').split('.')
+
+  let result = holder
+  while (propParts.length && result) {
+    result = result[propParts.shift()]
+  }
+
+  return result
+}
+
+/**
+ * Set property defined by dot notation in string.
+ * Based on https://github.com/lukeed/dset (MIT)
+ *
+ * @param  {Object} holder   Target object where to look property up
+ * @param  {string} propName Dot notation, like 'this.a.b.c'
+ * @param  {*}      value    The value to be set
+ */
+export function setProp (holder, propName, value) {
+  const propParts = Array.isArray(propName) ? propName : (propName + '').split('.')
+  let i = 0, l = propParts.length, t = holder, x
+
+  for (; i < l; ++i) {
+    x = t[propParts[i]]
+    t = t[propParts[i]] = (i === l - 1 ? value : (x != null ? x : (!!~propParts[i + 1].indexOf('.') || !(+propParts[i + 1] > -1)) ? {} : []))
+  }
+}


Test patch:
diff --git a/tests/dummy/data/post.js b/tests/dummy/data/post.js
index 6bc4404..db180c4 100644
--- a/tests/dummy/data/post.js
+++ b/tests/dummy/data/post.js
@@ -7,5 +7,15 @@ export const Post =
       firstname: 'John',
       lastname: 'Doe',
       age: 25
+    },
+    relationships: {
+      tags: [
+        {
+          name: 'super'
+        },
+        {
+          name: 'awesome'
+        }
+      ]
     }
   }
diff --git a/tests/dummy/data/postEmbed.js b/tests/dummy/data/postEmbed.js
index 96e89dd..1596d89 100644
--- a/tests/dummy/data/postEmbed.js
+++ b/tests/dummy/data/postEmbed.js
@@ -7,6 +7,18 @@ export const Post = {
       firstname: 'John',
       lastname: 'Doe',
       age: 25
+    },
+    relationships: {
+      tags: {
+        data: [
+          {
+            name: 'super'
+          },
+          {
+            name: 'awesome'
+          }
+        ]
+      }
     }
   }
 }
diff --git a/tests/dummy/data/posts.js b/tests/dummy/data/posts.js
index eaadead..807ab50 100644
--- a/tests/dummy/data/posts.js
+++ b/tests/dummy/data/posts.js
@@ -7,6 +7,16 @@ export const Posts = [
       firstname: 'John',
       lastname: 'Doe',
       age: 25
+    },
+    relationships: {
+      tags: [
+        {
+          name: 'super'
+        },
+        {
+          name: 'awesome'
+        }
+      ]
     }
   },
   {
@@ -17,6 +27,16 @@ export const Posts = [
       firstname: 'Mary',
       lastname: 'Doe',
       age: 25
+    },
+    relationships: {
+      tags: [
+        {
+          name: 'super'
+        },
+        {
+          name: 'awesome'
+        }
+      ]
     }
   }
 ]
diff --git a/tests/dummy/data/postsEmbed.js b/tests/dummy/data/postsEmbed.js
index 82a8cbe..581327c 100644
--- a/tests/dummy/data/postsEmbed.js
+++ b/tests/dummy/data/postsEmbed.js
@@ -8,6 +8,16 @@ export const Posts = {
         firstname: 'John',
         lastname: 'Doe',
         age: 25
+      },
+      relationships: {
+        tags: [
+          {
+            name: 'super'
+          },
+          {
+            name: 'awesome'
+          }
+        ]
       }
     },
     {
@@ -18,6 +28,18 @@ export const Posts = {
         firstname: 'Mary',
         lastname: 'Doe',
         age: 25
+      },
+      relationships: {
+        tags: {
+          data: [
+            {
+              name: 'super'
+            },
+            {
+              name: 'awesome'
+            }
+          ]
+        }
       }
     }
   ]
diff --git a/tests/dummy/models/Post.js b/tests/dummy/models/Post.js
index ade629f..228aec2 100644
--- a/tests/dummy/models/Post.js
+++ b/tests/dummy/models/Post.js
@@ -1,6 +1,7 @@
 import BaseModel from './BaseModel'
 import Comment from './Comment'
 import User from './User'
+import Tag from './Tag'
 
 export default class Post extends BaseModel {
   comments () {
@@ -9,7 +10,8 @@ export default class Post extends BaseModel {
 
   relations () {
     return {
-      user: User
+      user: User,
+      'relationships.tags': Tag
     }
   }
 }
diff --git a/tests/dummy/models/Tag.js b/tests/dummy/models/Tag.js
new file mode 100644
index 0000000..28b6e7a
--- /dev/null
+++ b/tests/dummy/models/Tag.js
@@ -0,0 +1,5 @@
+import BaseModel from './BaseModel'
+
+export default class Tag extends BaseModel {
+  //
+}
diff --git a/tests/model.test.js b/tests/model.test.js
index 29a59c2..2fb467d 100644
--- a/tests/model.test.js
+++ b/tests/model.test.js
@@ -3,13 +3,14 @@ import User from './dummy/models/User'
 import Comment from './dummy/models/Comment'
 import { Model } from '../src'
 import axios from 'axios'
-import MockAdapter from 'axios-mock-adapter';
+import MockAdapter from 'axios-mock-adapter'
 import { Posts as postsResponse } from './dummy/data/posts'
 import { Posts as postsEmbedResponse } from './dummy/data/postsEmbed'
 import { Post as postResponse } from './dummy/data/post'
 import { Post as postEmbedResponse } from './dummy/data/postEmbed'
 import { Comments as commentsResponse } from './dummy/data/comments'
 import { Comments as commentsEmbedResponse } from './dummy/data/commentsEmbed'
+import Tag from './dummy/models/Tag'
 
 describe('Model methods', () => {
 
@@ -50,6 +51,9 @@ describe('Model methods', () => {
     expect(post).toEqual(postsResponse[0])
     expect(post).toBeInstanceOf(Post)
     expect(post.user).toBeInstanceOf(User)
+    post.relationships.tags.forEach(tag => {
+      expect(tag).toBeInstanceOf(Tag)
+    })
   })
 
   test('$first() returns first object in array as instance of such Model', async () => {
@@ -61,6 +65,9 @@ describe('Model methods', () => {
     expect(post).toEqual(postsEmbedResponse.data[0])
     expect(post).toBeInstanceOf(Post)
     expect(post.user).toBeInstanceOf(User)
+    post.relationships.tags.forEach(tag => {
+      expect(tag).toBeInstanceOf(Tag)
+    })
   })
 
   test('first() method returns a empty object when no items have found', async () => {
@@ -77,6 +84,9 @@ describe('Model methods', () => {
     expect(post).toEqual(postResponse)
     expect(post).toBeInstanceOf(Post)
     expect(post.user).toBeInstanceOf(User)
+    post.relationships.tags.forEach(tag => {
+      expect(tag).toBeInstanceOf(Tag)
+    })
   })
 
   test('$find() handles request with "data" wrapper', async () => {
@@ -87,6 +97,9 @@ describe('Model methods', () => {
     expect(post).toEqual(postEmbedResponse.data)
     expect(post).toBeInstanceOf(Post)
     expect(post.user).toBeInstanceOf(User)
+    post.relationships.tags.data.forEach(tag => {
+      expect(tag).toBeInstanceOf(Tag)
+    })
   })
 
   test('$find() handles request without "data" wrapper', async () => {
@@ -97,7 +110,9 @@ describe('Model methods', () => {
     expect(post).toEqual(postResponse)
     expect(post).toBeInstanceOf(Post)
     expect(post.user).toBeInstanceOf(User)
-
+    post.relationships.tags.forEach(tag => {
+      expect(tag).toBeInstanceOf(Tag)
+    })
   })
 
   test('get() method returns a array of objects as instance of suchModel', async () => {
@@ -108,7 +123,10 @@ describe('Model methods', () => {
     posts.forEach(post => {
       expect(post).toBeInstanceOf(Post)
       expect(post.user).toBeInstanceOf(User)
-    });
+      post.relationships.tags.forEach(tag => {
+        expect(tag).toBeInstanceOf(Tag)
+      })
+    })
   })
 
   test('get() hits right resource (nested object)', async () => {
@@ -225,6 +243,16 @@ describe('Model methods', () => {
         firstname: 'John',
         lastname: 'Doe',
         age: 25
+      },
+      relationships: {
+        tags: [
+          {
+            name: 'super'
+          },
+          {
+            name: 'awesome'
+          }
+        ]
       }
     }
 
@@ -242,6 +270,9 @@ describe('Model methods', () => {
     expect(post).toEqual(_postResponse)
     expect(post).toBeInstanceOf(Post)
     expect(post.user).toBeInstanceOf(User)
+    post.relationships.tags.forEach(tag => {
+      expect(tag).toBeInstanceOf(Tag)
+    })
   })
 
   test('save() method makes a PUT request when ID of object exists', async () => {
@@ -320,6 +351,22 @@ describe('Model methods', () => {
     comment.save()
   })
 
+  test('save() method makes a POST request when ID of object is null', async () => {
+    let post
+
+    axiosMock.onAny().reply((config) => {
+      expect(config.method).toEqual('post')
+      expect(config.data).toEqual(JSON.stringify(post))
+      expect(config.url).toEqual('http://localhost/posts')
+
+      return [200, {}]
+    })
+
+    post = new Post({ id: null, title: 'Cool!' })
+    await post.save()
+
+  })
+
   test('a request from delete() method hits the right resource', async () => {
 
     axiosMock.onAny().reply((config) => {
@@ -474,7 +521,7 @@ describe('Model methods', () => {
 
     posts.forEach(post => {
       expect(post).toBeInstanceOf(Post)
-    });
+    })
   })
 
   test('attach() method hits right endpoint with a POST request', async () => {
diff --git a/tests/utils.test.js b/tests/utils.test.js
new file mode 100644
index 0000000..6dc0703
--- /dev/null
+++ b/tests/utils.test.js
@@ -0,0 +1,33 @@
+import { getProp, setProp } from '../src/utils'
+
+describe('Utilities', () => {
+  test('Get property defined by dot notation in string.', () => {
+    const holder = {
+      a: {
+        b: {
+          c: 1
+        }
+      }
+    }
+
+    const result = getProp(holder, 'a.b.c')
+
+    expect(result).toBe(1)
+  })
+
+  test('Set property defined by dot notation in string.', () => {
+    const holder = {
+      a: {
+        b: {
+          c: 1
+        }
+      }
+    }
+
+    setProp(holder, 'a.b.c', 2)
+
+    const result = getProp(holder, 'a.b.c')
+
+    expect(result).toBe(2)
+  })
+})