From e6524ec8757a43e198c4de22ce661478f2fb05b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Antunes=20Silva?= Date: Sun, 1 Nov 2020 14:23:17 -0300 Subject: [PATCH 01/10] ci: test files before upload coverage report. Test is required in order to generate coverage report. --- .github/workflows/test-and-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-and-release.yml b/.github/workflows/test-and-release.yml index a3f39af1..e31e7835 100644 --- a/.github/workflows/test-and-release.yml +++ b/.github/workflows/test-and-release.yml @@ -80,6 +80,9 @@ jobs: - name: Build run: yarn build + - name: Test + run: yarn test + - name: Upload Coverage to Codecov uses: codecov/codecov-action@v1 with: From 4b7baff239908f6e04639c5725830854fb0a3046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Antunes=20Silva?= Date: Sun, 1 Nov 2020 19:17:50 -0300 Subject: [PATCH 02/10] ci: disable travis --- .travis.yml | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f4ccecba..00000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -language: - node_js -node_js: - - 8 -install: - - npm install -g codecov -script: - - yarn && yarn cc && codecov From ebfd9b59e03a4311e49d601124620dee5bd7c29a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Antunes=20Silva?= Date: Tue, 3 Nov 2020 00:52:34 -0300 Subject: [PATCH 03/10] chore: add github actions badge to `readme.md` --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5a88013d..c6cfebc0 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,20 @@

- - + + + + + - + - - - - + + + +

# Elegant and simple way to build requests for REST API From 0d71cf45872711dc76a0b09607f7a0d765f81451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Antunes=20Silva?= Date: Fri, 6 Nov 2020 15:32:55 -0300 Subject: [PATCH 04/10] refactor: use `dotprop` and `dset` instead of `utils.js` (#145) Add dependencies `dotprop` and `dset` in order to remove `utils.js`. --- package.json | 4 + src/Model.js | 7 +- src/utils.js | 46 ----------- tests/utils.test.js | 186 -------------------------------------------- yarn.lock | 46 +++-------- 5 files changed, 20 insertions(+), 269 deletions(-) delete mode 100644 src/utils.js delete mode 100644 tests/utils.test.js diff --git a/package.json b/package.json index 850bea2e..ffb7c852 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,9 @@ "eslint": "^6.8.0", "jest": "^24.1.0", "semantic-release": "^17.2.2" + }, + "dependencies": { + "dotprop": "^1.2.0", + "dset": "^2.0.1" } } diff --git a/src/Model.js b/src/Model.js index 1510716f..601c2dc9 100644 --- a/src/Model.js +++ b/src/Model.js @@ -1,6 +1,7 @@ -import Builder from './Builder'; -import StaticModel from './StaticModel'; -import { getProp, setProp } from './utils' +import getProp from 'dotprop' +import setProp from 'dset' +import Builder from './Builder' +import StaticModel from './StaticModel' export default class Model extends StaticModel { diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 01843f79..00000000 --- a/src/utils.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Get property defined by dot notation in string. - * - * Based on {@link https://github.com/dy/dotprop } (MIT) - * - * @param {Object} holder - Target object where to look property up. - * @param {string | string[]} propName - Dot notation, like `'a.b.c'` or `['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 {@link https://github.com/lukeed/dset} (MIT) - * - * @param {Object} holder - Target object where to look property up. - * @param {string | string[]} propName - Dot notation, like `'a.b.c'` or `['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)) ? {} : [])) - } -} diff --git a/tests/utils.test.js b/tests/utils.test.js deleted file mode 100644 index fac116aa..00000000 --- a/tests/utils.test.js +++ /dev/null @@ -1,186 +0,0 @@ -import { getProp, setProp } from '../src/utils' - -describe('Utilities', () => { - /** - * Tests of `getProp` - * Based on tests from https://github.com/dy/dotprop (MIT) - */ - - test('[getProp]: 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('[getProp]: Get property defined by array-type keys.', () => { - const holder = { - a: { - b: { - c: 1 - } - } - } - - const result = getProp(holder, ['a', 'b', 'c']) - - expect(result).toBe(1) - }) - - test('[getProp]: Get property defined by simple string.', () => { - const holder = { - a: { - b: { - c: 1 - } - } - } - - const result = getProp(holder, 'a') - - expect(result).toBe(holder.a) - }) - - test('[getProp]: Get holder when propName is not defined.', () => { - const holder = { - a: { - b: { - c: 1 - } - } - } - - // @ts-ignore - const result = getProp(holder) - - expect(result).toBe(holder) - }) - - test('[getProp]: Get empty object when holder is not defined.', () => { - // @ts-ignore - const result = getProp() - - expect(result).toStrictEqual({}) - }) - - /** - * Tests of `setProp` - * Based on tests from https://github.com/lukeed/dset (MIT) - */ - - test('[setProp]: Does not return output', () => { - const foo = { a: 1, b: 2 } - const out = setProp(foo, 'c', 3) - - expect(out).toBeUndefined() - }) - - test('[setProp]: Mutates; adds simple key:val', () => { - const foo = { a: 1, b: 2 } - setProp(foo, 'c', 3) - - expect(foo).toStrictEqual({ a: 1, b: 2, c: 3 }) - }) - - test('[setProp]: Mutates; adds deeply nested key:val', () => { - const foo = {} - - // Add deep - setProp(foo, 'a.b.c', 999) - - expect(foo).toStrictEqual({ a: { b: { c: 999 } } }) - }) - - test('[setProp]: Mutates; changes the value via array-type keys', () => { - const foo = {} - - // Add deep - setProp(foo, ['a', 'b', 'c'], 123) - - expect(foo).toStrictEqual({ a: { b: { c: 123 } } }) - }) - - test('[setProp]: Mutates; changes the value via array-type keys and add array with value in index "0"', () => { - const foo = {} - - setProp(foo, ['x', '0', 'z'], 123) - - expect(foo).toStrictEqual({ x: [{ z: 123 }] }) - expect(Array.isArray(foo.x)).toBeTruthy() - }) - - test('[setProp]: Mutates; changes the value via array-type keys and add array with value in index "1"', () => { - const foo = {} - - setProp(foo, ['x', '1', 'z'], 123) - - expect(foo).toEqual({ x: [undefined, { z: 123 }] }) - expect(Array.isArray(foo.x)).toBeTruthy() - }) - - test('[setProp]: Mutates; changes the value via array-type keys, but as "10.0" is float, it doesn\'t create an array', () => { - const foo = {} - - setProp(foo, ['x', '10.0', 'z'], 123) - - expect(foo).toStrictEqual({ x: { '10.0': { z: 123 } } }) - expect(Array.isArray(foo.x)).toBeFalsy() - }) - - test('[setProp]: Mutates; changes the value via array-type keys, but as "10.2" is float, it doesn\'t create an array', () => { - const foo = {} - - setProp(foo, ['x', '10.2', 'z'], 123) - expect(foo).toStrictEqual({ x: { '10.2': { z: 123 } } }) - expect(Array.isArray(foo.x)).toBeFalsy() - }) - - test('[setProp]: Mutates; can create arrays when key is numeric', () => { - const foo = { a: 1 } - - // Create arrays instead of objects - setProp(foo, 'e.0.0', 2) - - expect(foo.e[0][0]).toStrictEqual(2) - expect(foo).toStrictEqual({ a: 1, e: [[2]] }) - expect(Array.isArray(foo.e)).toBeTruthy() - }) - - test('[setProp]: Mutates; writes into/preserves existing object', () => { - const foo = { a: { b: { c: 123 } } } - - // Preserve existing structure - setProp(foo, 'a.b.x.y', 456) - - expect(foo).toStrictEqual({ a: { b: { c: 123, x: { y: 456 } } } }) - }) - - test('[setProp]: Refuses to convert existing non-object value into object', () => { - const foo = { a: { b: 123 } } - const error = () => { - // Preserve non-object value, won't alter - setProp(foo, 'a.b.c', 'hello') - } - - expect(error).toThrow('Cannot create property \'c\' on number \'123\'') - - expect(foo.a.b).toStrictEqual(123) - expect(foo).toStrictEqual({ a: { b: 123 } }) - }) - - test('[setProp]: Mutates; writes into existing object w/ array value', () => { - const foo = { a: { b: { c: 123, d: { e: 5 } } } } - - // Preserve object tree, with array value - setProp(foo, 'a.b.d.z', [1,2,3,4]) - - expect(foo.a.b.d).toStrictEqual({ e: 5, z: [1, 2, 3, 4] }) - }) -}) diff --git a/yarn.lock b/yarn.lock index caebf2a0..57831421 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2437,7 +2437,7 @@ debug@^4.0.0: dependencies: ms "2.1.2" -debuglog@*, debuglog@^1.0.1: +debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI= @@ -2584,6 +2584,16 @@ dotenv@^5.0.1: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-5.0.1.tgz#a5317459bd3d79ab88cff6e44057a6a3fbb1fcef" integrity sha512-4As8uPrjfwb7VXC+WnLCbXK7y+Ueb2B3zgNCePYfhxS1PYeaO1YTeplffTEcbfLhvFNGLAz90VvJs9yomG7bow== +dotprop@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/dotprop/-/dotprop-1.2.0.tgz#8fdf345c757da479ec8af218ae4239a73df721a7" + integrity sha512-mVQb8y5u3UkzNua2Hc8Ut/uKyCjm9GG2MRk/0fxJ9Mxo8Nb8XyWqaP0wVXerMucmu0mQmlcZm3S1mjOdcbCwQA== + +dset@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/dset/-/dset-2.0.1.tgz#a15fff3d1e4d60ac0c95634625cbd5441a76deb1" + integrity sha512-nI29OZMRYq36hOcifB6HTjajNAAiBKSXsyWZrq+VniusseuP2OpNlTiYgsaNRSGvpyq5Wjbc2gQLyBdTyWqhnQ== + duplexer2@~0.1.0: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" @@ -3676,7 +3686,7 @@ import-local@^2.0.0: pkg-dir "^3.0.0" resolve-cwd "^2.0.0" -imurmurhash@*, imurmurhash@^0.1.4: +imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= @@ -4878,11 +4888,6 @@ lockfile@^1.0.4: dependencies: signal-exit "^3.0.2" -lodash._baseindexof@*: - version "3.1.0" - resolved "https://registry.yarnpkg.com/lodash._baseindexof/-/lodash._baseindexof-3.1.0.tgz#fe52b53a1c6761e42618d654e4a25789ed61822c" - integrity sha1-/lK1OhxnYeQmGNZU5KJXie1hgiw= - lodash._baseuniq@~4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8" @@ -4891,33 +4896,11 @@ lodash._baseuniq@~4.6.0: lodash._createset "~4.0.0" lodash._root "~3.0.0" -lodash._bindcallback@*: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" - integrity sha1-5THCdkTPi1epnhftlbNcdIeJOS4= - -lodash._cacheindexof@*: - version "3.0.2" - resolved "https://registry.yarnpkg.com/lodash._cacheindexof/-/lodash._cacheindexof-3.0.2.tgz#3dc69ac82498d2ee5e3ce56091bafd2adc7bde92" - integrity sha1-PcaayCSY0u5ePOVgkbr9Ktx73pI= - -lodash._createcache@*: - version "3.1.2" - resolved "https://registry.yarnpkg.com/lodash._createcache/-/lodash._createcache-3.1.2.tgz#56d6a064017625e79ebca6b8018e17440bdcf093" - integrity sha1-VtagZAF2JeeevKa4AY4XRAvc8JM= - dependencies: - lodash._getnative "^3.0.0" - lodash._createset@~4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26" integrity sha1-D0ZZ+7CddRlPqeK4imZE02PJ/iY= -lodash._getnative@*, lodash._getnative@^3.0.0: - version "3.9.1" - resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" - integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U= - lodash._root@~3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692" @@ -4953,11 +4936,6 @@ lodash.isstring@^4.0.1: resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= -lodash.restparam@*: - version "3.6.1" - resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" - integrity sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU= - lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" From faf3d1ead825d95a60276f29affed13cfffb06e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Antunes=20Silva?= Date: Mon, 9 Nov 2020 00:14:49 -0300 Subject: [PATCH 05/10] feat(builder): add support to nested filters (#141) Add nested filters support to `where` and `whereIn`. --- docs/content/en/api/query-builder-methods.md | 20 +++++++ docs/content/en/building-the-query.md | 54 ++++++++++++++++++ src/Builder.js | 60 ++++++++++++++++++-- tests/builder.test.js | 11 +++- 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/docs/content/en/api/query-builder-methods.md b/docs/content/en/api/query-builder-methods.md index e254337a..a9b8b914 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:** + +Available in version >= v1.8.0 + +```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:** + +Available in version >= v1.8.0 + +```js +await Model.whereIn(['user', 'id'], [1, 2, 3]) +``` + ## `orderBy` - Arguments: `(...args)` - Returns: `self` diff --git a/docs/content/en/building-the-query.md b/docs/content/en/building-the-query.md index 27178999..2457b581 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`: +#### Nested Filter + +Available in version >= v1.8.0 + +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`: + + + + + ```js + const posts = await Post.where([ + 'user', 'status' + ], 'active').get() + ``` + + + + + ```http request + GET /posts?filter[user][status]=active + ``` + + + + ### 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 +#### Nested Filter + +Available in version >= v1.8.0 + +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`: + + + + + ```js + const posts = await Post.whereIn(['user', 'status'], [ + 'active', 'inactive' + ]).get() + ``` + + + + + ```http request + GET /posts?filter[user][status]=active,inactive + ``` + + + + ## Sorting See the [API reference](/api/query-builder-methods#orderby) diff --git a/src/Builder.js b/src/Builder.js index 9fb0eb8d..2a75633f 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/tests/builder.test.js b/tests/builder.test.js index ab08938c..5a83e556 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 +}) From e46d63ef343d06de277e339e38ca561d4519828a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Antunes=20Silva?= Date: Mon, 9 Nov 2020 00:40:43 -0300 Subject: [PATCH 06/10] feat(model): add support to configuration at query builder (#142) 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. --- docs/content/en/api/crud-operations.md | 31 ++++++ docs/content/en/api/query-builder-methods.md | 16 +++ docs/content/en/building-the-query.md | 21 +++- docs/content/en/performing-operations.md | 32 ++++++ docs/static/sw.js | 100 +++-------------- src/Model.js | 106 +++++++++++++------ tests/model.test.js | 63 +++++++++++ 7 files changed, 248 insertions(+), 121 deletions(-) diff --git a/docs/content/en/api/crud-operations.md b/docs/content/en/api/crud-operations.md index 0161e5c1..905c1578 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. +## `patch` +- Returns: `Model | { data: Model }` + +Make a `PATCH` request to update a model in the database, then return the instance. + + + + + ```js + const model = await Model.find(1) + + model.foo = 'bar' + + model.patch() + ``` + + + + + ```http request + PATCH /resource/1 + ``` + + + + +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 a9b8b914..f2ed7ad1 100644 --- a/docs/content/en/api/query-builder-methods.md +++ b/docs/content/en/api/query-builder-methods.md @@ -180,6 +180,22 @@ Build custom endpoints. +## `config` +Available in version >= v1.8.0 + +- 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 2457b581..ad6be300 100644 --- a/docs/content/en/building-the-query.md +++ b/docs/content/en/building-the-query.md @@ -614,6 +614,21 @@ We can build a resource to get the latest `Posts` that belongs to a **User**: +## Configuring the Request + +Available in version >= v1.8.0 + +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 @@ -640,8 +655,10 @@ We can get a list of **Posts** that belongs to an **User**: -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: diff --git a/docs/content/en/performing-operations.md b/docs/content/en/performing-operations.md index 55f23314..ff8a9920 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**: +And if we want to use `PATCH`, we can easily do that using [patch](/api/crud-operations#patch). + + + + + ```js + const post = await Post.find(1) + + post.text = 'An updated text for our Post!' + + await post.patch() + ``` + + + + + ```http request + GET /posts/1 + ``` + + + + + ```http request + PATCH /posts/1 + ``` + + + + +You can safely use `PATCH` with `save()`. The `POST` method will not be overridden, only `PUT`. + ### Deleting a Model See the [API reference](/api/crud-operations#delete). diff --git a/docs/static/sw.js b/docs/static/sw.js index d4f0b2ee..2d4d4602 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/Model.js b/src/Model.js index 601c2dc9..6e9b8807 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) } } diff --git a/tests/model.test.js b/tests/model.test.js index 9183dbf3..59add3c2 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 From a93cf5e54e6d53523c823cc304d29d290870b31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Antunes=20Silva?= Date: Mon, 9 Nov 2020 22:09:06 -0300 Subject: [PATCH 07/10] feat(model): add support to upload files (#143) Add support to upload files by checking if data has instance of `File`. If so, it set the `Content-Type` to `multipart/form-data` and convert the data object to `FormData` using `object-to-formdata`. Based on comment by @alvaro-canepa at #83 --- docs/content/en/api/crud-operations.md | 3 + docs/content/en/performing-operations.md | 2 + package.json | 3 +- src/Model.js | 51 ++++++ tests/model.test.js | 189 +++++++++++++++++++++++ yarn.lock | 5 + 6 files changed, 252 insertions(+), 1 deletion(-) diff --git a/docs/content/en/api/crud-operations.md b/docs/content/en/api/crud-operations.md index 905c1578..3289ac2f 100644 --- a/docs/content/en/api/crud-operations.md +++ b/docs/content/en/api/crud-operations.md @@ -10,6 +10,8 @@ category: API Save or update a model in the database, then return the instance. +When uploading files, the `Content-Type` will be set to `multipart/form-data`. + ### create @@ -96,6 +98,7 @@ Alias for: model.config({ method: 'PATCH' }).save() ``` +When uploading files, the `Content-Type` will be set to `multipart/form-data`. ## `delete` diff --git a/docs/content/en/performing-operations.md b/docs/content/en/performing-operations.md index ff8a9920..1f2162d8 100644 --- a/docs/content/en/performing-operations.md +++ b/docs/content/en/performing-operations.md @@ -61,6 +61,8 @@ We can create a new **Post**: +When uploading files, the `Content-Type` will be set to `multipart/form-data`. + Then we can update our newly created **Post**: diff --git a/package.json b/package.json index ffb7c852..982fd814 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ }, "dependencies": { "dotprop": "^1.2.0", - "dset": "^2.0.1" + "dset": "^2.0.1", + "object-to-formdata": "^4.1.0" } } diff --git a/src/Model.js b/src/Model.js index 6e9b8807..12150b61 100644 --- a/src/Model.js +++ b/src/Model.js @@ -1,3 +1,4 @@ +import { serialize } from 'object-to-formdata' import getProp from 'dotprop' import setProp from 'dset' import Builder from './Builder' @@ -41,6 +42,32 @@ export default class Model extends StaticModel { return this } + formData(options = {}) { + const defaultOptions = { + /** + * Include array indices in FormData keys + */ + indices: false, + + /** + * Treat null values like undefined values and ignore them + */ + nullsAsUndefineds: false, + + /** + * Convert true or false to 1 or 0 respectively + */ + booleansAsIntegers: false, + + /** + * Store arrays even if they're empty + */ + allowEmptyArrays: false, + } + + return { ...defaultOptions, ...options } + } + resource() { return `${this.constructor.name.toLowerCase()}s` } @@ -285,6 +312,7 @@ export default class Model extends StaticModel { _reqConfig(config, options = { forceMethod: false }) { const _config = { ...config, ...this._config } + // Prevent default request method from being overridden if (options.forceMethod) { _config.method = config.method } @@ -296,6 +324,29 @@ export default class Model extends StaticModel { Object.entries(_config.data) .filter(([key]) => !key.startsWith('_')) ) + + const _hasFiles = Object.keys(_config.data).some(property => { + if (Array.isArray(_config.data[property])) { + return _config.data[property].some(value => value instanceof File) + } + + return _config.data[property] instanceof File + }) + + // Check if the data has files + if (_hasFiles) { + // Check if `config` has `headers` property + if (!('headers' in _config)) { + // If not, then set an empty object + _config.headers = {} + } + + // Set header Content-Type + _config.headers['Content-Type'] = 'multipart/form-data' + + // Convert object to form data + _config.data = serialize(_config.data, this.formData()) + } } return _config diff --git a/tests/model.test.js b/tests/model.test.js index 59add3c2..eae156d5 100644 --- a/tests/model.test.js +++ b/tests/model.test.js @@ -446,6 +446,195 @@ describe('Model methods', () => { }) }) + test('save() method makes a POST request when ID of object does not exists, with header "Content-Type: multipart/form-data" if the data has files', async () => { + let post + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }) + const _postResponse = { + id: 1, + title: 'Cool!' + } + + axiosMock.onAny().reply((config) => { + let _data + + if (config.headers['Content-Type'] === 'multipart/form-data') { + _data = Object.fromEntries(config.data) + + if (_data['files[]']) { + _data.files = [{}, {}] + delete _data['files[]'] + } + + _data = JSON.stringify(_data) + } else { + _data = config.data + } + + expect(config.method).toEqual('post') + expect(config.headers['Content-Type']).toEqual('multipart/form-data') + expect(_data).toEqual(JSON.stringify(post)) + expect(config.url).toEqual('http://localhost/posts') + + return [200, _postResponse] + }) + + // Single files + post = new Post({ title: 'Cool!', file }) + await post.save() + + // Multiple files + post = new Post({ title: 'Cool!', files: [file, file] }) + await post.save() + }) + + test('save() method makes a PUT request when ID of when ID of object exists, with header "Content-Type: multipart/form-data" if the data has files', async () => { + let post + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }) + const _postResponse = { + id: 1, + title: 'Cool!' + } + + axiosMock.onAny().reply((config) => { + let _data + + if (config.headers['Content-Type'] === 'multipart/form-data') { + _data = Object.fromEntries(config.data) + _data.id = 1 + + if (_data['files[]']) { + _data.files = [{}, {}] + delete _data['files[]'] + } + + _data = JSON.stringify(_data) + } else { + _data = config.data + } + + expect(config.method).toEqual('put') + expect(config.headers['Content-Type']).toEqual('multipart/form-data') + expect(_data).toEqual(JSON.stringify(post)) + expect(config.url).toEqual('http://localhost/posts/1') + + return [200, _postResponse] + }) + + // Single file + post = new Post({ id: 1, title: 'Cool!', file }) + await post.save() + + // Multiple files + post = new Post({ id: 1, title: 'Cool!', files: [file, file] }) + await post.save() + }) + + test('patch() method makes a PATCH request when ID of when ID of object exists, with header "Content-Type: multipart/form-data" if the data has files', async () => { + let post + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }) + const _postResponse = { + id: 1, + title: 'Cool!' + } + + axiosMock.onAny().reply((config) => { + let _data + const _post = post + delete _post._config + + if (config.headers['Content-Type'] === 'multipart/form-data') { + _data = Object.fromEntries(config.data) + _data.id = 1 + + if (_data['files[]']) { + _data.files = [{}, {}] + delete _data['files[]'] + } + + _data = JSON.stringify(_data) + } else { + _data = config.data + } + + expect(config.method).toEqual('patch') + expect(config.headers['Content-Type']).toEqual('multipart/form-data') + expect(_data).toEqual(JSON.stringify(_post)) + expect(config.url).toEqual('http://localhost/posts/1') + + return [200, _postResponse] + }) + + // Single file + post = new Post({ id: 1, title: 'Cool!', file }) + await post.patch() + + // Multiple files + post = new Post({ id: 1, title: 'Cool!', files: [file, file] }) + await post.patch() + }) + + test('save() method can add header "Content-Type: multipart/form-data" when "headers" object is already defined', async () => { + let post + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }) + 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) => { + let _data + const _post = post + delete _post._config + + if (config.headers['Content-Type'] === 'multipart/form-data') { + _data = JSON.stringify(Object.fromEntries(config.data)) + } else { + _data = config.data + } + + expect(config.method).toEqual('post') + expect(config.headers['Content-Type']).toStrictEqual('multipart/form-data') + expect(_data).toEqual(JSON.stringify(_post)) + expect(config.url).toEqual('http://localhost/posts') + + return [200, _postResponse] + }) + + post = new Post({ title: 'Cool!', file }) + post = await post.config({ headers: {} }).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 diff --git a/yarn.lock b/yarn.lock index 57831421..51f8cc5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5729,6 +5729,11 @@ object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== +object-to-formdata@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object-to-formdata/-/object-to-formdata-4.1.0.tgz#0d7bdd6f9e4efa8c0075a770c11ec92b7bf6c560" + integrity sha512-4Ti3VLTspWOUt5QIBl5/BpvLBnr4tbFpZ/FpXKWQFqLslvGIB3ug3jurW/k8iIpoyE6HcZhhmQ6mFiOq1tKGEQ== + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" From 0d7ac004e407026c3d2e743989e16331579ff9be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Antunes=20Silva?= Date: Tue, 10 Nov 2020 11:56:42 -0300 Subject: [PATCH 08/10] feat(builder): accept array for `include`, `append` and `orderBy` (#148) Accept array of strings for `include`, `append` and `orderBy`. --- docs/content/en/api/query-builder-methods.md | 38 ++++++--- docs/content/en/building-the-query.md | 85 ++++++++++++++++++-- src/Builder.js | 15 ++-- tests/builder.test.js | 13 ++- 4 files changed, 127 insertions(+), 24 deletions(-) diff --git a/docs/content/en/api/query-builder-methods.md b/docs/content/en/api/query-builder-methods.md index f2ed7ad1..502aac94 100644 --- a/docs/content/en/api/query-builder-methods.md +++ b/docs/content/en/api/query-builder-methods.md @@ -15,6 +15,14 @@ Eager load relationships. await Model.include('user', 'category') ``` +#### Array + +Available in version >= v1.8.0 + +```js +await Model.include(['user', 'category']) +``` + ## `append` - Arguments: `(...args)` - Returns: `self` @@ -22,7 +30,15 @@ await Model.include('user', 'category') Append attributes. ```js -await Model.append('likes') +await Model.append('likes', 'shares') +``` + +#### Array + +Available in version >= v1.8.0 + +```js +await Model.append(['likes', 'shares']) ``` ## `select` @@ -31,12 +47,12 @@ await Model.append('likes') Set the columns to be selected. -**Single entity:** +#### Single entity ```js await Model.select(['title', 'content']) ``` -**Related entities:** +#### Related entities ```js await Post.select({ posts: ['title', 'content'], @@ -50,13 +66,11 @@ await Post.select({ Add a basic where clause to the query. -**Simple:** - ```js await Model.where('status', 'active') ``` -**Nested:** +#### Nested Available in version >= v1.8.0 @@ -70,13 +84,11 @@ await Model.where(['user', 'status'], 'active') Add a "where in" clause to the query. -**Simple:** - ```js await Model.whereIn('id', [1, 2, 3]) ``` -**Nested:** +#### Nested Available in version >= v1.8.0 @@ -94,6 +106,14 @@ Add an "order by" clause to the query. await Model.orderBy('-created_at', 'category_id') ``` +#### Array + +Available in version >= v1.8.0 + +```js +await Model.orderBy(['-created_at', 'category_id']) +``` + ## `page` - Arguments: `(value)` - Returns: `self` diff --git a/docs/content/en/building-the-query.md b/docs/content/en/building-the-query.md index ad6be300..cea56a59 100644 --- a/docs/content/en/building-the-query.md +++ b/docs/content/en/building-the-query.md @@ -334,7 +334,7 @@ We also need to sort our queries, so let's do this now! The method we want to use now is `orderBy`. The arguments are the names of the properties we want to sort. We can pass as many arguments as we want. -**Single Sort** +#### Single Sort We can sort our **Posts** by the `created_at` date: @@ -355,7 +355,7 @@ We can sort our **Posts** by the `created_at` date: -**Multiple Sort** +#### Multiple Sort And we can sort by their `title` too: @@ -380,6 +380,29 @@ And we can sort by their `title` too: Sorting is ascending by default and can be reversed by adding a hyphen (-) to the start of the property name. +#### Using an Array + +Available in version >= v1.8.0 + +The first argument of `orderBy` also accepts an array of string. + + + + + ```js + const posts = await Post.orderBy(['-created_at', 'title']).get() + ``` + + + + + ```http request + GET /posts?sort=-created_at + ``` + + + + ## Including Relationships See the [API reference](/api/query-builder-methods#include) @@ -387,20 +410,43 @@ See the [API reference](/api/query-builder-methods#include) Sometimes, we will want to eager load a relationship, and to do so, we can use the `include` method. The arguments are the names of the relationships we want to include. We can pass as many arguments as we want. -Let's eager load the `category` relationship of our **Post**: +Let's eager load the relationships `category` and `tags` of our **Post**: + + + + + ```js + const posts = await Post.include('category', 'tags').get() + ``` + + + + + ```http request + GET /posts?include=category,tags + ``` + + + + +#### Using an Array + +Available in version >= v1.8.0 + +The first argument of `include` also accepts an array of string. ```js - const posts = await Post.include('category').get() + const posts = await Post.include(['category', 'tags']).get() ``` ```http request - GET /posts?include=category + GET /posts?include=category,tags ``` @@ -413,20 +459,43 @@ See the [API reference](/api/query-builder-methods#append) We can also append attributes to our queries using the `append` method. The arguments are the names of the attributes we want to append. We can pass as many arguments as we want. -Let's append the `likes` attribute of our **Post**: +Let's append the attribute `likes` and `shares` of our **Post**: + + + + + ```js + const posts = await Post.append('likes', 'shares').get() + ``` + + + + + ```http request + GET /posts?append=likes,shares + ``` + + + + +#### Using an Array + +Available in version >= v1.8.0 + +The first argument of `append` also accepts an array of string. ```js - const posts = await Post.append('likes').get() + const posts = await Post.append(['likes', 'shares']).get() ``` ```http request - GET /posts?append=likes + GET /posts?append=likes,shares ``` diff --git a/src/Builder.js b/src/Builder.js index 2a75633f..af127cc3 100644 --- a/src/Builder.js +++ b/src/Builder.js @@ -63,14 +63,16 @@ export default class Builder { * Query builder */ - include(...args) { - this.includes = args + include(...relationships) { + relationships = Array.isArray(relationships[0]) ? relationships[0] : relationships + this.includes = relationships return this } - append(...args) { - this.appends = args + append(...attributes) { + attributes = Array.isArray(attributes[0]) ? attributes[0] : attributes + this.appends = attributes return this } @@ -131,8 +133,9 @@ export default class Builder { return this } - orderBy(...args) { - this.sorts = args + orderBy(...fields) { + fields = Array.isArray(fields[0]) ? fields[0] : fields + this.sorts = fields return this } diff --git a/tests/builder.test.js b/tests/builder.test.js index 5a83e556..3676b566 100644 --- a/tests/builder.test.js +++ b/tests/builder.test.js @@ -64,6 +64,10 @@ describe('Query builder', () => { post = Post.include('user', 'category') expect(post._builder.includes).toEqual(['user', 'category']) + + post = Post.include(['user', 'category']) + + expect(post._builder.includes).toEqual(['user', 'category']) }) test('append() sets properly the builder', () => { @@ -74,6 +78,10 @@ describe('Query builder', () => { post = Post.append('likes', 'visits') expect(post._builder.appends).toEqual(['likes', 'visits']) + + post = Post.append(['likes', 'visits']) + + expect(post._builder.appends).toEqual(['likes', 'visits']) }) test('orderBy() sets properly the builder', () => { @@ -84,6 +92,10 @@ describe('Query builder', () => { post = Post.orderBy('created_at', '-visits') expect(post._builder.sorts).toEqual(['created_at', '-visits']) + + post = Post.orderBy(['created_at', '-visits']) + + expect(post._builder.sorts).toEqual(['created_at', '-visits']) }) test('where() sets properly the builder', () => { @@ -141,7 +153,6 @@ describe('Query builder', () => { expect(errorModel).toThrow('The second argument on whereIn() method must be an array.') }) - test('page() sets properly the builder', () => { let post = Post.page(3) From db3951155c8b3965194457410ed7e5d9979dc182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Antunes=20Silva?= Date: Tue, 10 Nov 2020 12:13:16 -0300 Subject: [PATCH 09/10] feat(model): add methods `with`, `all` and `$all` (#147) `with` is alias for `include`, `all` is alias for `get` and `$all` is alias for `$get`. --- docs/content/en/api/query-builder-methods.md | 10 +++++++--- docs/content/en/building-the-query.md | 4 ++-- src/Model.js | 12 ++++++++++++ src/StaticModel.js | 19 +++++++++++++++++++ tests/builder.test.js | 10 ++++++++++ tests/model.test.js | 18 ++++++++++++++++++ 6 files changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/content/en/api/query-builder-methods.md b/docs/content/en/api/query-builder-methods.md index 502aac94..93bc51ab 100644 --- a/docs/content/en/api/query-builder-methods.md +++ b/docs/content/en/api/query-builder-methods.md @@ -23,6 +23,8 @@ await Model.include('user', 'category') await Model.include(['user', 'category']) ``` +`with` is an alias of this method. + ## `append` - Arguments: `(...args)` - Returns: `self` @@ -225,6 +227,8 @@ Execute the query as a "select" statement. await Model.get() ``` +`all` is an alias of this method. + ## `first` - Returns: `Model | { data: Model }` @@ -249,15 +253,15 @@ await Model.find(1) Execute the query as a "select" statement. -These `$`-prefixed convenience methods always return the requested content as [`JSON`](https://developer.mozilla.org/en-US/docs/Web/API/Body/json). - ```js await Model.$get() ``` -These `$`-prefixed convenience methods always return the requested content. +These `$`-prefixed convenience methods always return the requested content. They handle and unwrap responses within "data". +`$all` is an alias of this method. + ## `$first` - Returns: `Model` diff --git a/docs/content/en/building-the-query.md b/docs/content/en/building-the-query.md index cea56a59..9eae5ab8 100644 --- a/docs/content/en/building-the-query.md +++ b/docs/content/en/building-the-query.md @@ -14,7 +14,7 @@ With our models already set up, it's time to start using them! See the [API reference](/api/query-builder-methods#get) Let's start initializing a model and building a simple query that gets all records from the database. -To achieve this, we can use the `get` method. +To achieve this, we can use the `get` method or its alias `all`. We can get a list of posts using the **Post** model: @@ -407,7 +407,7 @@ The first argument of `orderBy` also accepts an array of string. See the [API reference](/api/query-builder-methods#include) -Sometimes, we will want to eager load a relationship, and to do so, we can use the `include` method. +Sometimes, we will want to eager load a relationship, and to do so, we can use the `include` method or its alias `with`. The arguments are the names of the relationships we want to include. We can pass as many arguments as we want. Let's eager load the relationships `category` and `tags` of our **Post**: diff --git a/src/Model.js b/src/Model.js index 12150b61..3464292d 100644 --- a/src/Model.js +++ b/src/Model.js @@ -213,6 +213,10 @@ export default class Model extends StaticModel { return this } + with(...args) { + return this.include(...args) + } + append(...args) { this._builder.append(...args) @@ -428,6 +432,14 @@ export default class Model extends StaticModel { .then(response => response.data || response) } + all() { + return this.get() + } + + $all() { + return this.$get() + } + /** * Common CRUD operations */ diff --git a/src/StaticModel.js b/src/StaticModel.js index b07c7e9f..0c9cc180 100644 --- a/src/StaticModel.js +++ b/src/StaticModel.js @@ -18,6 +18,13 @@ export default class StaticModel { return self } + static with(...args) { + let self = this.instance() + self.with(...args) + + return self + } + static append(...args) { let self = this.instance() self.append(...args) @@ -111,9 +118,21 @@ export default class StaticModel { return self.get() } + static all() { + let self = this.instance() + + return self.all() + } + static $get() { let self = this.instance() return self.$get() } + + static $all() { + let self = this.instance() + + return self.$all() + } } diff --git a/tests/builder.test.js b/tests/builder.test.js index 3676b566..c1006622 100644 --- a/tests/builder.test.js +++ b/tests/builder.test.js @@ -70,6 +70,16 @@ describe('Query builder', () => { expect(post._builder.includes).toEqual(['user', 'category']) }) + test('with() sets properly the builder', () => { + let post = Post.with('user') + + expect(post._builder.includes).toEqual(['user']) + + post = Post.with('user', 'category') + + expect(post._builder.includes).toEqual(['user', 'category']) + }) + test('append() sets properly the builder', () => { let post = Post.append('likes') diff --git a/tests/model.test.js b/tests/model.test.js index eae156d5..385970f3 100644 --- a/tests/model.test.js +++ b/tests/model.test.js @@ -265,6 +265,24 @@ describe('Model methods', () => { }) }) + test('all() method should be an alias of get() method', async () => { + axiosMock.onGet('http://localhost/posts').reply(200, postsResponse) + + const postsAll = await Post.all() + const postsGet = await Post.get() + + expect(postsAll).toStrictEqual(postsGet) + }) + + test('$all() method should be an alias of $get() method', async () => { + axiosMock.onGet('http://localhost/posts').reply(200, postsEmbedResponse) + + const postsAll = await Post.$all() + const postsGet = await Post.$get() + + expect(postsAll).toStrictEqual(postsGet) + }) + test('save() method makes a POST request when ID of object does not exists', async () => { let post const _postResponse = { From 1c612b5adf8e57f8331590e77a07e719fe6f4f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Antunes=20Silva?= Date: Tue, 10 Nov 2020 12:16:23 -0300 Subject: [PATCH 10/10] chore: update "Contributors" section of README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c6cfebc0..b6c49ae7 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Thanks to the following people who have contributed to this project: * [@JoaoPedroAS51](https://github.com/JoaoPedroAS51) * [@Peter-Krebs](https://github.com/Peter-Krebs) +[See all contributors](https://github.com/robsontenorio/vue-api-query/graphs/contributors) + ## Thanks * Inspiration from [milroyfraser/sarala](https://github.com/milroyfraser/sarala).