Repository: robsontenorio/vue-api-query (js)

Issue:
Discussion about array format breaking change
There is a discussion about 60986893880c6a2e6c274a14a4b0855ff476b1f8 introducing a breaking change in the array format when parsing the query. After reading the reported issues (see  #214) and making some tests, I can confirm that it indeed changes the array formatting, but for [custom parameters](https://robsontenorio.github.io/vue-api-query/building-the-query#applying-custom-parameters) **only**. This is not a bad thing at all as per our documentation, this was our intended behaviour in the first place. Our goal is to match [spatie/laravel-query-builder](https://github.com/spatie/laravel-query-builder).

In the [custom parameters docs](https://robsontenorio.github.io/vue-api-query/building-the-query#applying-custom-parameters) we have the following example:
```js
const posts = await Post.params({
  doSomething: 'yes',
  process: false,
  multiple: ['awesome', 'amazing', 'super']
}).get()
```
Which should parse to: 
See [spatie/laravel-query-builder filtering docs](https://spatie.be/docs/laravel-query-builder/v5/features/filtering#content-basic-usage)
```http request
GET /posts?doSomething=yes&process=false&multiple=awesome,amazing,super
```

But it was actually parsing to:
```http request
GET /posts?doSomething=yes&process=false&multiple[0]=awesome&multiple[1]=amazing&multiple[2]=super
```

So as you can see here, the commit 60986893880c6a2e6c274a14a4b0855ff476b1f8 fixes the behaviour to parse as the documentation.

But I understand that this can lead to issues for some use cases, so the best thing to do is adding an option to change the parser array format.

There was an attempt to add this option, see #248. The forked repo is currently unavailable, so I'm reproducing these changes locally and will be opening a new PR. 

One thing to note though is that by changing the array format to `indices`, it will not only change the custom parameters, but the parsing of all methods. For example, the `whereIn` method with nested params will be parsed to:
```js
const posts = await Post.whereIn(['user', 'status'], [
  'active', 'inactive'
]).get()
```
```http request
GET /posts?filter[user][status][0]=active&filter[user][status][1]=inactive
```

cc @robsontenorio 

Pull request description:
feat(model): add method `stringifyOptions`
See #253 for context.

This PR adds `stringifyOptions` method, which allows configuring `qs.stringify`:

```js
class BaseModel extends Model {
  stringifyOptions() {
    return {
      arrayFormat: 'indices'
    }
  }
}
```


Thanks to @ricardo-lobo and his PR #248 

Closes #253


Patch:
diff --git a/docs/content/en/api/model-options.md b/docs/content/en/api/model-options.md
index 0510ff6..8fa5179 100644
--- a/docs/content/en/api/model-options.md
+++ b/docs/content/en/api/model-options.md
@@ -111,6 +111,23 @@ formData() {
 }
 ```
 
+### `stringifyOptions`
+- Default: `{ encode: false, arrayFormat: 'comma' }`
+- Returns: `object`
+
+This method can be overridden in the model to configure `qs`.
+
+See [qs](https://github.com/ljharb/qs#stringifying)
+
+```js
+stringifyOptions() {
+  return {
+    encode: false,
+    arrayFormat: 'comma',
+  }
+}
+```
+
 ## Model Options
 
 These are model-related options.
diff --git a/docs/content/en/configuration.md b/docs/content/en/configuration.md
index a5ed2e5..68e138b 100644
--- a/docs/content/en/configuration.md
+++ b/docs/content/en/configuration.md
@@ -296,6 +296,52 @@ export default class Model extends BaseModel {
 }
 ```
 
+## Configuring Query Parameters Parser
+
+See the [API reference](/api/model-options#stringifyOptions) and [qs](https://github.com/ljharb/qs#stringifying)
+
+We may also need to configure the parser to match our needs. By default, it is configured to match 
+`spatie/laravel-query-builder`, which uses `comma` array format.
+
+If we want, for example, to change this behaviour to `indices`, we can configure the stringify options of `qs` 
+by overriding the `stringifyOptions` method.
+
+We can globally configure this in the [Base Model](/configuration#creating-a-base-model):
+
+```js{}[~/models/Model.js]
+import { Model as BaseModel } from 'vue-api-query'
+
+export default class Model extends BaseModel {
+
+  // Define a base url for a REST API
+  baseURL() {
+    return 'http://my-api.com'
+  }
+
+  // Implement a default request method
+  request(config) {
+    return this.$http.request(config)
+  }
+
+  // Override default query parameter names
+  parameterNames() {
+    const defaultParams = super.parameterNames()
+    const customParams = {
+      include: 'include_custom'
+    }
+    
+    return { ...defaultParams, ...customParams }
+  }
+  
+  // Configure qs
+  stringifyOptions() {
+    return {
+      arrayFormat: 'indices'
+    }
+  }
+}
+```
+
 ## Configuring FormData
 
 See the [API reference](/api/model-options#formdata) and [object-to-formdata](https://github.com/therealparmesh/object-to-formdata#usage)
@@ -331,6 +377,13 @@ export default class Model extends BaseModel {
     return { ...defaultParams, ...customParams }
   }
   
+  // Configure qs
+  stringifyOptions() {
+    return {
+      arrayFormat: 'indices'
+    }
+  }
+  
   // Configure object-to-formadata
   formData() {
     return {
diff --git a/index.d.ts b/index.d.ts
index 8f351cb..c3e34b9 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,3 +1,5 @@
+import type { IStringifyOptions } from 'qs'
+
 type Method =
   | 'get'
   | 'GET'
@@ -443,6 +445,14 @@ export class Model extends StaticModel {
     limit: string
   }
 
+  /**
+   * This method can be overridden in the model to configure `qs`.
+   *
+   * @see {@link https://robsontenorio.github.io/vue-api-query/api/model-options#stringifyOptions|API Reference}
+   * @see {@link https://github.com/ljharb/qs#stringifying|qs}
+   */
+  protected stringifyOptions(): IStringifyOptions
+
   /**
    * Query
    */
diff --git a/package.json b/package.json
index f07db80..634a583 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
     "semantic-release": "^19.0.3"
   },
   "dependencies": {
+    "@types/qs": "^6.9.7",
     "defu": "^6.0.0",
     "dotprop": "^1.2.0",
     "dset": "^3.1.2",
diff --git a/src/Model.js b/src/Model.js
index c069374..533990f 100644
--- a/src/Model.js
+++ b/src/Model.js
@@ -188,6 +188,12 @@ export default class Model extends StaticModel {
     }
   }
 
+  stringifyOptions() {
+    return {
+      arrayFormat: 'comma'
+    }
+  }
+
   /**
    *  Query
    */
diff --git a/src/Parser.js b/src/Parser.js
index ebea6d4..d88e845 100644
--- a/src/Parser.js
+++ b/src/Parser.js
@@ -106,7 +106,10 @@ export default class Parser {
     let fields = { [this.parameterNames().fields]: this.builder.fields }
     this.uri +=
       this.prepend() +
-      qs.stringify(fields, { encode: false, arrayFormat: 'comma' })
+      qs.stringify(fields, {
+        encode: false,
+        ...this.builder.model.stringifyOptions()
+      })
   }
 
   filters() {
@@ -117,7 +120,10 @@ export default class Parser {
     let filters = { [this.parameterNames().filter]: this.builder.filters }
     this.uri +=
       this.prepend() +
-      qs.stringify(filters, { encode: false, arrayFormat: 'comma' })
+      qs.stringify(filters, {
+        encode: false,
+        ...this.builder.model.stringifyOptions()
+      })
   }
 
   sorts() {
@@ -159,7 +165,7 @@ export default class Parser {
       this.prepend() +
       qs.stringify(this.builder.payload, {
         encode: false,
-        arrayFormat: 'comma'
+        ...this.builder.model.stringifyOptions()
       })
   }
 }
diff --git a/yarn.lock b/yarn.lock
index 5e68b7a..83c1c3e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1448,6 +1448,11 @@
   resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
   integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
 
+"@types/qs@^6.9.7":
+  version "6.9.7"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
+  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
+
 "@types/retry@^0.12.0":
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"


Test patch:
diff --git a/tests/builder.test.js b/tests/builder.test.js
index 207229b..97268dc 100644
--- a/tests/builder.test.js
+++ b/tests/builder.test.js
@@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter'
 import { Model } from '../src'
 import ModelWithParamNames from './dummy/models/ModelWithParamNames'
 import Post from './dummy/models/Post'
+import PostWithOptions from './dummy/models/PostWithOptions'
 
 describe('Query builder', () => {
   let errorModel = {}
@@ -56,6 +57,36 @@ describe('Query builder', () => {
     expect(post._builder.query()).toEqual(query)
   })
 
+  test('it can change default array format option', () => {
+    let post = PostWithOptions.include('user').whereIn('status', [
+      'published',
+      'archived'
+    ])
+
+    expect(post._builder.query()).toEqual(
+      '?include=user&filter[status][0]=published&filter[status][1]=archived'
+    )
+
+    expect(post._builder.filters).toEqual({
+      status: ['published', 'archived']
+    })
+
+    post = PostWithOptions.include('user').whereIn(
+      ['user', 'status'],
+      ['active', 'inactive']
+    )
+
+    expect(post._builder.query()).toEqual(
+      '?include=user&filter[user][status][0]=active&filter[user][status][1]=inactive'
+    )
+
+    expect(post._builder.filters).toEqual({
+      user: {
+        status: ['active', 'inactive']
+      }
+    })
+  })
+
   test('include() sets properly the builder', () => {
     let post = Post.include('user')
 
diff --git a/tests/dummy/models/PostWithOptions.js b/tests/dummy/models/PostWithOptions.js
new file mode 100644
index 0000000..d5b73d0
--- /dev/null
+++ b/tests/dummy/models/PostWithOptions.js
@@ -0,0 +1,23 @@
+import BaseModel from './BaseModel'
+import Comment from './Comment'
+import Tag from './Tag'
+import User from './User'
+
+export default class PostWithOptions extends BaseModel {
+  comments() {
+    return this.hasMany(Comment)
+  }
+
+  relations() {
+    return {
+      user: User,
+      'relationships.tags': Tag
+    }
+  }
+
+  stringifyOptions() {
+    return {
+      arrayFormat: 'indices'
+    }
+  }
+}