Repository: robsontenorio/vue-api-query
Pull request #142 (2020-11-06 01:37:29)

feat(model): add support to configuration at query builder
Add a new method `config` to `Model.js`. It can be used to override the http instance (e.g. axios) configuration at query builder.
It can be used to change the method of update request to `PATCH`, to add additional data to the request, to add headers, and so on.

### TODO

- [x] Update documentation
- [x] Remove method `updateMethod`

### Usage
```js
await Model.config({ method: 'PATCH' }).save()

await Model.config({ headers: { ... }, data: { foo: 'bar' } }).save()
```


Patch:
diff --git a/docs/content/en/api/crud-operations.md b/docs/content/en/api/crud-operations.md
index 0161e5c..905c157 100644
--- a/docs/content/en/api/crud-operations.md
+++ b/docs/content/en/api/crud-operations.md
@@ -65,6 +65,37 @@ Save or update a model in the database, then return the instance.
   </code-block>
 </code-group>
 
+## `patch`
+- Returns: `Model | { data: Model }`
+
+Make a `PATCH` request to update a model in the database, then return the instance.
+
+<code-group>
+  <code-block Label="Query" active>
+
+  ```js
+  const model = await Model.find(1)
+  
+  model.foo = 'bar'
+  
+  model.patch()
+  ```
+
+  </code-block>
+  <code-block Label="Request">
+
+  ```http request
+  PATCH /resource/1
+  ```
+
+  </code-block>
+</code-group>
+
+Alias for:
+```js
+model.config({ method: 'PATCH' }).save()
+```
+
 
 ## `delete`
 
diff --git a/docs/content/en/api/query-builder-methods.md b/docs/content/en/api/query-builder-methods.md
index e254337..f2ed7ad 100644
--- a/docs/content/en/api/query-builder-methods.md
+++ b/docs/content/en/api/query-builder-methods.md
@@ -50,20 +50,40 @@ await Post.select({
 
 Add a basic where clause to the query.
 
+**Simple:**
+
 ```js
 await Model.where('status', 'active')
 ```
 
+**Nested:**
+
+<alert type="success">Available in version >= v1.8.0</alert>
+
+```js
+await Model.where(['user', 'status'], 'active')
+```
+
 ## `whereIn`
 - Arguments: `(field, array)`
 - Returns: `self`
 
 Add a "where in" clause to the query.
 
+**Simple:**
+
 ```js
 await Model.whereIn('id', [1, 2, 3])
 ```
 
+**Nested:**
+
+<alert type="success">Available in version >= v1.8.0</alert>
+
+```js
+await Model.whereIn(['user', 'id'], [1, 2, 3])
+```
+
 ## `orderBy`
 - Arguments: `(...args)`
 - Returns: `self`
@@ -160,6 +180,22 @@ Build custom endpoints.
   </code-block>
 </code-group>
 
+## `config`
+<alert type="success">Available in version >= v1.8.0</alert>
+
+- Arguments: `(config)`
+- Returns: `self`
+
+Configuration of HTTP Instance.
+
+```js
+await Model.config({
+  method: 'PATCH',
+  header: { /* ... */ },
+  data: { foo: 'bar' }
+}).save()
+```
+
 ## `get`
 - Returns: `Collection | { data: Collection }`
 
diff --git a/docs/content/en/building-the-query.md b/docs/content/en/building-the-query.md
index 2717899..ad6be30 100644
--- a/docs/content/en/building-the-query.md
+++ b/docs/content/en/building-the-query.md
@@ -242,6 +242,33 @@ We can filter our **Posts** to only get results where `status` is `published`:
   </code-block>
 </code-group>
 
+#### Nested Filter
+
+<alert type="success">Available in version >= v1.8.0</alert>
+
+The first argument of `where` also accepts an array of keys, which are used to build a nested filter.
+
+So we can filter our **Posts** to only get results where `status` of `user` is `active`:
+
+<code-group>
+  <code-block label="Query" active>
+
+  ```js
+  const posts = await Post.where([
+    'user', 'status'
+  ], 'active').get()
+  ```
+
+  </code-block>
+  <code-block label="Request">
+
+  ```http request
+  GET /posts?filter[user][status]=active
+  ```
+
+  </code-block>
+</code-group>
+
 ### Evaluating Multiple Values
 
 See the [API reference](/api/query-builder-methods#wherein)
@@ -271,6 +298,33 @@ We can filter our **Posts** to only get results where `status` is `published` or
   </code-block>
 </code-group>
 
+#### Nested Filter
+
+<alert type="success">Available in version >= v1.8.0</alert>
+
+The first argument of `whereIn` also accepts an array of keys, which are used to build a nested filter.
+
+So we can filter our **Posts** to only get results where `status` of `user` is `active` or `inactive`:
+
+<code-group>
+  <code-block label="Query" active>
+
+  ```js
+  const posts = await Post.whereIn(['user', 'status'], [
+    'active', 'inactive'
+  ]).get()
+  ```
+
+  </code-block>
+  <code-block label="Request">
+
+  ```http request
+  GET /posts?filter[user][status]=active,inactive
+  ```
+
+  </code-block>
+</code-group>
+
 ## Sorting
 
 See the [API reference](/api/query-builder-methods#orderby)
@@ -560,6 +614,21 @@ We can build a resource to get the latest `Posts` that belongs to a **User**:
   </code-block>
 </code-group>
 
+## Configuring the Request
+
+<alert type="success">Available in version >= v1.8.0</alert>
+
+See the [API reference](/api/query-builder-methods#config)
+
+The `config` method can be used to configure the current request at query builder. We can pass any config available 
+from the HTTP Instance. If we are using [Axios](https://github.com/axios/axios), 
+we should pass an [AxiosRequestConfig](https://github.com/axios/axios#request-config).
+
+We can add headers, change the method, anything we want:
+
+```js
+await Post.config({ headers: { /*...*/ } }).get()
+```
 
 ## Needless Parent Request
 
@@ -586,8 +655,10 @@ We can get a list of **Posts** that belongs to an **User**:
   </code-block>
 </code-group>
 
-And the same thing using for the example above, if we want to define a dynamic resource, 
-we can create a new **User** instance with the ID:
+And the same thing can be done if we want to define a 
+[dynamic resource](/building-the-query#defining-a-dynamic-resource).
+
+We can create a new **User** instance with the ID:
  
 <code-group>
   <code-block label="Query" active>
diff --git a/docs/content/en/performing-operations.md b/docs/content/en/performing-operations.md
index 55f2331..ff8a992 100644
--- a/docs/content/en/performing-operations.md
+++ b/docs/content/en/performing-operations.md
@@ -91,6 +91,38 @@ Then we can update our newly created **Post**:
   </code-block>
 </code-group>
 
+And if we want to use `PATCH`, we can easily do that using [patch](/api/crud-operations#patch).
+
+<code-group>
+  <code-block Label="Query" active>
+
+  ```js
+  const post = await Post.find(1)
+  
+  post.text = 'An updated text for our Post!'
+  
+  await post.patch()
+  ```
+
+  </code-block>
+  <code-block Label="Find Request">
+
+  ```http request
+  GET /posts/1
+  ```
+
+  </code-block>
+  <code-block Label="Save Request">
+
+  ```http request
+  PATCH /posts/1
+  ```
+
+  </code-block>
+</code-group>
+
+<alert type="info">You can safely use `PATCH` with `save()`. The `POST` method will not be overridden, only `PUT`.</alert>
+
 ### Deleting a Model
 
 See the [API reference](/api/crud-operations#delete).
diff --git a/docs/static/sw.js b/docs/static/sw.js
index d4f0b2e..2d4d460 100644
--- a/docs/static/sw.js
+++ b/docs/static/sw.js
@@ -1,91 +1,17 @@
-const options = {"workboxURL":"https://cdn.jsdelivr.net/npm/workbox-cdn@5.1.3/workbox/workbox-sw.js","importScripts":[],"config":{"debug":false},"clientsClaim":true,"skipWaiting":true,"cleanupOutdatedCaches":true,"offlineAnalytics":false,"preCaching":["/vue-api-query/?standalone=true"],"runtimeCaching":[{"urlPattern":"/vue-api-query/_nuxt/","handler":"CacheFirst","method":"GET","strategyPlugins":[]},{"urlPattern":"/vue-api-query/","handler":"NetworkFirst","method":"GET","strategyPlugins":[]}],"offlinePage":null,"pagesURLPattern":"/vue-api-query/","offlineStrategy":"NetworkFirst"}
+// THIS FILE SHOULD NOT BE VERSION CONTROLLED
 
-importScripts(...[options.workboxURL, ...options.importScripts])
+// https://github.com/NekR/self-destroying-sw
 
-initWorkbox(workbox, options)
-workboxExtensions(workbox, options)
-precacheAssets(workbox, options)
-cachingExtensions(workbox, options)
-runtimeCaching(workbox, options)
-offlinePage(workbox, options)
-routingExtensions(workbox, options)
+self.addEventListener('install', function (e) {
+  self.skipWaiting()
+})
 
-function getProp(obj, prop) {
-  return prop.split('.').reduce((p, c) => p[c], obj)
-}
-
-function initWorkbox(workbox, options) {
-  if (options.config) {
-    // Set workbox config
-    workbox.setConfig(options.config)
-  }
-
-  if (options.cacheNames) {
-    // Set workbox cache names
-    workbox.core.setCacheNameDetails(options.cacheNames)
-  }
-
-  if (options.clientsClaim) {
-    // Start controlling any existing clients as soon as it activates
-    workbox.core.clientsClaim()
-  }
-
-  if (options.skipWaiting) {
-    workbox.core.skipWaiting()
-  }
-
-  if (options.cleanupOutdatedCaches) {
-    workbox.precaching.cleanupOutdatedCaches()
-  }
-
-  if (options.offlineAnalytics) {
-    // Enable offline Google Analytics tracking
-    workbox.googleAnalytics.initialize()
-  }
-}
-
-function precacheAssets(workbox, options) {
-  if (options.preCaching.length) {
-    workbox.precaching.precacheAndRoute(options.preCaching, options.cacheOptions)
-  }
-}
-
-function runtimeCaching(workbox, options) {
-  for (const entry of options.runtimeCaching) {
-    const urlPattern = new RegExp(entry.urlPattern)
-    const method = entry.method || 'GET'
-
-    const plugins = (entry.strategyPlugins || [])
-      .map(p => new (getProp(workbox, p.use))(...p.config))
-
-    const strategyOptions = { ...entry.strategyOptions, plugins }
-
-    const strategy = new workbox.strategies[entry.handler](strategyOptions)
-
-    workbox.routing.registerRoute(urlPattern, strategy, method)
-  }
-}
-
-function offlinePage(workbox, options) {
-  if (options.offlinePage) {
-    // Register router handler for offlinePage
-    workbox.routing.registerRoute(new RegExp(options.pagesURLPattern), ({ request, event }) => {
-      const strategy = new workbox.strategies[options.offlineStrategy]
-      return strategy
-        .handle({ request, event })
-        .catch(() => caches.match(options.offlinePage))
+self.addEventListener('activate', function (e) {
+  self.registration.unregister()
+    .then(function () {
+      return self.clients.matchAll()
     })
-  }
-}
-
-function workboxExtensions(workbox, options) {
-  
-}
-
-function cachingExtensions(workbox, options) {
-  
-}
-
-function routingExtensions(workbox, options) {
-  
-}
+    .then(function (clients) {
+      clients.forEach(client => client.navigate(client.url))
+    })
+})
diff --git a/src/Builder.js b/src/Builder.js
index 9fb0eb8..2a75633 100644
--- a/src/Builder.js
+++ b/src/Builder.js
@@ -3,6 +3,7 @@
  */
 
 import Parser from './Parser';
+import setProp from 'dset'
 
 export default class Builder {
 
@@ -21,11 +22,43 @@ export default class Builder {
     this.parser = new Parser(this)
   }
 
-  // query string parsed 
+  // query string parsed
   query() {
     return this.parser.query()
   }
 
+  /**
+   * Helpers
+   */
+
+  /**
+   * Nested filter via array-type keys.
+   *
+   * @example
+   * const [_key, _value] = this._nestedFilter(keys, value)
+   * this.filters[_key] = _value
+   *
+   * @param {string[]} keys - Array-type keys, like `['a', 'b', 'c']`.
+   * @param {*} value - The value to be set.
+   *
+   * @return {[]} - An array containing the first key, which is the index to be used in `filters`
+   * object, and a value, which is the nested filter.
+   *
+   */
+  _nestedFilter (keys, value) {
+    // Get first key from `keys` array, then remove it from array
+    const _key = keys.shift()
+    // Initialize an empty object
+    const _value = {}
+
+    // Convert the keys into a deeply nested object, which the value of the deepest key is
+    // the `value` property.
+    // Then assign the object to `_value` property.
+    setProp(_value, keys, value)
+
+    return [_key, _value]
+  }
+
   /**
    * Query builder
    */
@@ -63,22 +96,37 @@ export default class Builder {
   }
 
   where(key, value) {
-    if (key === undefined || value === undefined)
+    if (key === undefined || value === undefined) {
       throw new Error('The KEY and VALUE are required on where() method.')
+    }
 
-    if (Array.isArray(value) || value instanceof Object)
+    if (Array.isArray(value) || value instanceof Object) {
       throw new Error('The VALUE must be primitive on where() method.')
+    }
 
-    this.filters[key] = value
+    if (Array.isArray(key)) {
+      const [_key, _value] = this._nestedFilter(key, value)
+
+      this.filters[_key] = _value
+    } else {
+      this.filters[key] = value
+    }
 
     return this
   }
 
   whereIn(key, array) {
-    if (!Array.isArray(array))
+    if (!Array.isArray(array)) {
       throw new Error('The second argument on whereIn() method must be an array.')
+    }
+
+    if (Array.isArray(key)) {
+      const [_key, _value] = this._nestedFilter(key, array.join(','))
 
-    this.filters[key] = array.join(',')
+      this.filters[_key] = _value
+    } else {
+      this.filters[key] = array.join(',')
+    }
 
     return this
   }
diff --git a/src/Model.js b/src/Model.js
index 601c2dc..6e9b880 100644
--- a/src/Model.js
+++ b/src/Model.js
@@ -36,6 +36,11 @@ export default class Model extends StaticModel {
     return Model.$http
   }
 
+  config(config) {
+    this._config = config
+    return this
+  }
+
   resource() {
     return `${this.constructor.name.toLowerCase()}s`
   }
@@ -277,6 +282,25 @@ export default class Model extends StaticModel {
     }
   }
 
+  _reqConfig(config, options = { forceMethod: false }) {
+    const _config = { ...config, ...this._config }
+
+    if (options.forceMethod) {
+      _config.method = config.method
+    }
+
+    // Check if config has data
+    if ('data' in _config) {
+      // Ditch private data
+      _config.data = Object.fromEntries(
+        Object.entries(_config.data)
+          .filter(([key]) => !key.startsWith('_'))
+      )
+    }
+
+    return _config
+  }
+
   first() {
     return this.get().then(response => {
       let item
@@ -304,10 +328,12 @@ export default class Model extends StaticModel {
     let base = this._fromResource || `${this.baseURL()}/${this.resource()}`
     let url = `${base}/${identifier}${this._builder.query()}`
 
-    return this.request({
-      url,
-      method: 'GET'
-    }).then(response => {
+    return this.request(
+      this._reqConfig({
+        url,
+        method: 'GET'
+      })
+    ).then(response => {
       return this._applyInstance(response.data)
     })
   }
@@ -327,10 +353,12 @@ export default class Model extends StaticModel {
     base = this._customResource ? `${this.baseURL()}/${this._customResource}` : base
     let url = `${base}${this._builder.query()}`
 
-    return this.request({
-      url,
-      method: 'GET'
-    }).then(response => {
+    return this.request(
+      this._reqConfig({
+        url,
+        method: 'GET'
+      })
+    ).then(response => {
       let collection = this._applyInstanceCollection(response.data)
 
       if (response.data.data !== undefined) {
@@ -358,10 +386,12 @@ export default class Model extends StaticModel {
       throw new Error('This model has a empty ID.')
     }
 
-    return this.request({
-      url: this.endpoint(),
-      method: 'DELETE'
-    }).then(response => response)
+    return this.request(
+      this._reqConfig({
+        method: 'DELETE',
+        url: this.endpoint()
+      })
+    ).then(response => response)
   }
 
   save() {
@@ -369,42 +399,54 @@ export default class Model extends StaticModel {
   }
 
   _create() {
-    return this.request({
-      method: 'POST',
-      url: this.endpoint(),
-      data: this
-    }).then(response => {
+    return this.request(
+      this._reqConfig({
+        method: 'POST',
+        url: this.endpoint(),
+        data: this
+      }, { forceMethod: true })
+    ).then(response => {
       return this._applyInstance(response.data.data || response.data)
     })
   }
 
   _update() {
-    return this.request({
-      method: 'PUT',
-      url: this.endpoint(),
-      data: this
-    }).then(response => {
+    return this.request(
+      this._reqConfig({
+        method: 'PUT',
+        url: this.endpoint(),
+        data: this
+      })
+    ).then(response => {
       return this._applyInstance(response.data.data || response.data)
     })
   }
 
+  patch() {
+    return this.config({ method: 'PATCH' }).save()
+  }
+
   /**
    * Relationship operations
    */
 
   attach(params) {
-    return this.request({
-      method: 'POST',
-      url: this.endpoint(),
-      data: params
-    }).then(response => response)
+    return this.request(
+      this._reqConfig({
+        method: 'POST',
+        url: this.endpoint(),
+        data: params
+      })
+    ).then(response => response)
   }
 
   sync(params) {
-    return this.request({
-      method: 'PUT',
-      url: this.endpoint(),
-      data: params
-    }).then(response => response)
+    return this.request(
+      this._reqConfig({
+        method: 'PUT',
+        url: this.endpoint(),
+        data: params
+      })
+    ).then(response => response)
   }
 }


Test patch:
diff --git a/tests/builder.test.js b/tests/builder.test.js
index ab08938..5a83e55 100644
--- a/tests/builder.test.js
+++ b/tests/builder.test.js
@@ -94,6 +94,11 @@ describe('Query builder', () => {
     post = Post.where('id', 1).where('title', 'Cool')
 
     expect(post._builder.filters).toEqual({ id: 1, title: 'Cool' })
+
+    post = Post.where(['user', 'status'], 'active')
+
+    expect(post._builder.filters).toEqual({ user: { status: 'active' } })
+    expect(post._builder.query()).toEqual('?filter[user][status]=active')
   })
 
   test('where() throws a exception when doest not have params or only first param', () => {
@@ -122,6 +127,10 @@ describe('Query builder', () => {
     let post = Post.whereIn('status', ['ACTIVE', 'ARCHIVED'])
 
     expect(post._builder.filters).toEqual({ status: 'ACTIVE,ARCHIVED' })
+
+    post = Post.whereIn(['user', 'status'], ['active', 'inactive'])
+
+    expect(post._builder.query()).toEqual('?filter[user][status]=active,inactive')
   })
 
   test('whereIn() throws a exception when second parameter is not a array', () => {
@@ -209,4 +218,4 @@ describe('Query builder', () => {
     expect(post._builder.query()).toEqual(query)
     expect(post._builder.query()).toEqual(query)
   })
-})
\ No newline at end of file
+})
diff --git a/tests/model.test.js b/tests/model.test.js
index 9183dbf..59add3c 100644
--- a/tests/model.test.js
+++ b/tests/model.test.js
@@ -383,6 +383,69 @@ describe('Model methods', () => {
     comment.save()
   })
 
+  test('save() method makes a PATCH request when method is set using `config`', async () => {
+    let post
+
+    axiosMock.onAny().reply((config) => {
+      const _post = post
+      delete _post._config
+
+      expect(config.method).toEqual('patch')
+      expect(config.data).toEqual(JSON.stringify(_post))
+      expect(config.url).toEqual('http://localhost/posts/1')
+
+      return [200, {}]
+    })
+
+    post = new Post({ id: 1, title: 'Cool!' })
+    await post.config({ method: 'PATCH' }).save()
+  })
+
+  test('save() method makes a POST request when ID of object does not exists, even when `config` set to PATCH', async () => {
+    let post
+    const _postResponse = {
+      id: 1,
+      title: 'Cool!',
+      text: 'Lorem Ipsum Dolor',
+      user: {
+        firstname: 'John',
+        lastname: 'Doe',
+        age: 25
+      },
+      relationships: {
+        tags: [
+          {
+            name: 'super'
+          },
+          {
+            name: 'awesome'
+          }
+        ]
+      }
+    }
+
+    axiosMock.onAny().reply((config) => {
+      const _post = post
+      delete _post._config
+
+      expect(config.method).toEqual('post')
+      expect(config.data).toEqual(JSON.stringify(_post))
+      expect(config.url).toEqual('http://localhost/posts')
+
+      return [200, _postResponse]
+    })
+
+    post = new Post({ title: 'Cool!' })
+    post = await post.config({ method: 'PATCH' }).save()
+
+    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 POST request when ID of object is null', async () => {
     let post