Skip to content

Commit ed8c14e

Browse files
committed
Custom queries
1 parent 59251a2 commit ed8c14e

File tree

10 files changed

+263
-12
lines changed

10 files changed

+263
-12
lines changed

docs/.vuepress/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ themeConfig:
2525
- /guide/persist/
2626
- /guide/push/
2727
- /guide/destroy/
28+
- /guide/custom-queries/
2829
- /guide/custom-mutations/
2930
- /guide/relationships/
3031
- /guide/eager-loading/

docs/guide/custom-mutations/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@ await Post.mutate({ mutation: 'upvotePost', id: post.id });
1616
await Post.dispatch('mutate', { mutation: 'upvotePost', id: post.id });
1717
```
1818

19-
As you can see you have to privide the mutation name and any further arguments you want to pass. In this case we send
20-
the post id, but this could be anything. This generates the following query:
19+
As you can see you have to provide the mutation name and any further arguments you want to pass. In this case we send
20+
the post id, but this could be anything else. Please also notice that `record.$mutate` automatically adds the id
21+
of the record into the arguments list. The plugin automatically determines if there are multiple records or a single
22+
record is requests by looking in the arguments hash if there is a `id` field and respectively setups the query.
23+
24+
A custom mutation is always tied to the model, so the plugin expects the return value of the custom query is of the
25+
model type. In this example that means, that Vuex-ORM-Apollo expects that the `upvotePost` mutation is of type `Post`.
26+
27+
This generates the following query:
2128

2229

2330
```graphql

docs/guide/custom-queries/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Custom Queries
2+
3+
[[toc]]
4+
5+
6+
You may sometimes want to send custom GraphQL query. We support this via the `query` action. However please notice that
7+
the convenienceMethods here are named `customMutation` and `$customMutation` due to a name conflict with the `query()`
8+
method Vuex-ORM.
9+
10+
```javascript
11+
const post = Post.query().first();
12+
await post.$customQuery({ query: 'example' });
13+
14+
// is the same as
15+
await Post.customQuery({ query: 'example', id: post.id });
16+
17+
// or
18+
await Post.dispatch('query', { query: 'example', id: post.id });
19+
```
20+
21+
As you can see you have to provide the query name and any further arguments you want to pass. In this case we send
22+
the post id, but this could be anything else. Please also notice that `record.$customQuery` automatically adds the id
23+
of the record into the arguments list. The plugin automatically determines if there are multiple records or a single
24+
record is requests by looking in the arguments hash if there is a `id` field and respectively setups the query.
25+
26+
A custom query is always tied to the model, so the plugin expects the return value of the custom query is of the model
27+
type. In this example that means, that Vuex-ORM-Apollo expects that the `example` query is of type `Post`.
28+
29+
This generates the following query:
30+
31+
32+
```graphql
33+
mutation Example($id: ID!) {
34+
example(post: $id) {
35+
id
36+
userId
37+
content
38+
title
39+
40+
user {
41+
id
42+
email
43+
}
44+
}
45+
}
46+
```
47+
48+
Variables:
49+
50+
```json
51+
{
52+
"id": 42
53+
}
54+
```
55+
56+
Like for all other operations, all records which are returned replace the respective existing records in the Vuex-ORM
57+
database.

src/actions/fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default class Fetch extends Action {
2727
// When the filter contains an id, we query in singular mode
2828
const multiple: boolean = !filter['id'];
2929
const name: string = NameGenerator.getNameForFetch(model, multiple);
30-
const query = QueryBuilder.buildQuery('query', model, name, filter, multiple);
30+
const query = QueryBuilder.buildQuery('query', model, name, filter, multiple, multiple);
3131

3232
// Send the request to the GraphQL API
3333
const data = await context.apollo.request(model, query, filter, false, bypassCache as boolean);

src/actions/query.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import QueryBuilder from '../graphql/query-builder';
2+
import Context from '../common/context';
3+
import { Store } from '../orm/store';
4+
import Transformer from '../graphql/transformer';
5+
import { ActionParams, Data } from '../support/interfaces';
6+
import Action from './action';
7+
import NameGenerator from '../graphql/name-generator';
8+
9+
/**
10+
* Query action for sending a custom query. Will be used for Model.customQuery() and record.$customQuery.
11+
*/
12+
export default class Query extends Action {
13+
/**
14+
* @param {any} state The Vuex state
15+
* @param {DispatchFunction} dispatch Vuex Dispatch method for the model
16+
* @param {ActionParams} params Optional params to send with the query
17+
* @returns {Promise<Data>} The fetched records as hash
18+
*/
19+
public static async call ({ state, dispatch }: ActionParams, params?: ActionParams): Promise<Data> {
20+
if (params && params.query) {
21+
const context = Context.getInstance();
22+
const model = this.getModelFromState(state);
23+
24+
// Filter
25+
const filter = params && params.filter ? Transformer.transformOutgoingData(model, params.filter) : {};
26+
const bypassCache = params && params.bypassCache;
27+
28+
// When the filter contains an id, we query in singular mode
29+
const multiple: boolean = !filter['id'];
30+
const name: string = params.query;
31+
const query = QueryBuilder.buildQuery('query', model, name, filter, multiple, false);
32+
33+
// Send the request to the GraphQL API
34+
const data = await context.apollo.request(model, query, filter, false, bypassCache as boolean);
35+
36+
// Insert incoming data into the store
37+
return Store.insertData(data, dispatch);
38+
} else {
39+
throw new Error("The customQuery action requires the query name ('query') to be set");
40+
}
41+
}
42+
}

src/graphql/query-builder.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,14 @@ export default class QueryBuilder {
2828
args?: Arguments,
2929
ignoreModels: Array<Model> = [],
3030
name?: string,
31+
filter: boolean = false,
3132
allowIdFields: boolean = false): string {
3233

3334
const context = Context.getInstance();
3435
model = context.getModel(model);
3536
ignoreModels.push(model);
3637

37-
let params: string = this.buildArguments(model, args, false, multiple, allowIdFields);
38+
let params: string = this.buildArguments(model, args, false, filter, allowIdFields);
3839

3940
const fields = `
4041
${model.getQueryFields().join(' ')}
@@ -66,9 +67,11 @@ export default class QueryBuilder {
6667
* @param {string} name Optional name of the query/mutation. Will overwrite the name from the model.
6768
* @param {Arguments} args Arguments for the query
6869
* @param {boolean} multiple Determines if the root query field is a connection or not (will be passed to buildField)
70+
* @param {boolean} filter When true the query arguments are passed via a filter object.
6971
* @returns {any} Whatever gql() returns
7072
*/
71-
public static buildQuery (type: string, model: Model | string, name?: string, args?: Arguments, multiple?: boolean) {
73+
public static buildQuery (type: string, model: Model | string, name?: string, args?: Arguments, multiple?: boolean,
74+
filter?: boolean) {
7275
const context = Context.getInstance();
7376

7477
// model
@@ -94,7 +97,7 @@ export default class QueryBuilder {
9497
// build query
9598
const query: string =
9699
`${type} ${upcaseFirstLetter(name)}${this.buildArguments(model, args, true, false)} {\n` +
97-
` ${this.buildField(model, multiple, args, [], name, true)}\n` +
100+
` ${this.buildField(model, multiple, args, [], name, filter, true)}\n` +
98101
`}`;
99102

100103
return gql(query);
@@ -234,7 +237,7 @@ export default class QueryBuilder {
234237
const multiple: boolean = !(field instanceof context.components.BelongsTo ||
235238
field instanceof context.components.HasOne);
236239

237-
relationQueries.push(this.buildField(relatedModel, multiple, undefined, ignoreModels, name));
240+
relationQueries.push(this.buildField(relatedModel, multiple, undefined, ignoreModels, name, false));
238241
}
239242
});
240243

src/support/interfaces.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface ActionParams {
2525
data?: Data;
2626
args?: Arguments;
2727
bypassCache?: boolean;
28+
query?: string;
2829
}
2930

3031
export interface Data {
@@ -47,6 +48,7 @@ export interface Field {
4748
}
4849

4950
export class PatchedModel extends ORMModel {
50-
static async fetch (filter: any, bypassCache = false): Promise<any> { return undefined; }
51+
static async fetch (filter: any, bypassCache: boolean = false): Promise<any> { return undefined; }
5152
static async mutate (params: any): Promise<any> { return undefined; }
53+
static async customQuery (query: string, params: any, bypassCache: boolean = false): Promise<any> { return undefined; }
5254
}

src/vuex-orm-apollo.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PatchedModel, Options } from './support/interfaces';
22
import Context from './common/context';
33
import { Components } from '@vuex-orm/core/lib/plugins/use';
44
import { Destroy, Fetch, Mutate, Persist, Push } from './actions';
5+
import Query from './actions/query';
56

67
/**
78
* Main class of the plugin. Setups the internal context, Vuex actions and model methods
@@ -29,11 +30,12 @@ export default class VuexORMApollo {
2930
context.components.subActions.push = Push.call.bind(Push);
3031
context.components.subActions.destroy = Destroy.call.bind(Destroy);
3132
context.components.subActions.mutate = Mutate.call.bind(Mutate);
33+
context.components.subActions.query = Query.call.bind(Query);
3234
}
3335

3436
/**
35-
* This method will setup following model methods: Model.fetch, Model.mutate, record.$mutate, record.$persist,
36-
* record.$push, record.$destroy and record.$deleteAndDestroy
37+
* This method will setup following model methods: Model.fetch, Model.mutate, Model.customQuery, record.$mutate,
38+
* record.$persist, record.$push, record.$destroy and record.$deleteAndDestroy, record.$customQuery
3739
*/
3840
private static setupModelMethods () {
3941
const context = Context.getInstance();
@@ -49,6 +51,11 @@ export default class VuexORMApollo {
4951
return this.dispatch('mutate', params);
5052
};
5153

54+
(context.components.Model as (typeof PatchedModel)).customQuery = async function (query: string, filter: any,
55+
bypassCache = false) {
56+
return this.dispatch('query', { query, filter, bypassCache });
57+
};
58+
5259
// Register model convenience methods
5360
const model = context.components.Model.prototype;
5461

@@ -57,6 +64,11 @@ export default class VuexORMApollo {
5764
return this.$dispatch('mutate', params);
5865
};
5966

67+
model.$customQuery = async function (query: string, filter: any, bypassCache: boolean = false) {
68+
if (!filter['id']) filter['id'] = this.id;
69+
return this.$dispatch('query', { query, filter, bypassCache });
70+
};
71+
6072
model.$persist = async function (args: any) {
6173
return this.$dispatch('persist', { id: this.id, args });
6274
};

test/integration/VuexORMApollo.spec.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,133 @@ mutation DeleteUser($id: ID!) {
357357
});
358358

359359

360+
describe('custom query', () => {
361+
it('via Model method sends the correct query to the API', async () => {
362+
const response = {
363+
data: {
364+
unpublishedPosts: {
365+
nodes: [
366+
{
367+
__typename: 'post',
368+
id: 1,
369+
otherId: 13548,
370+
published: false,
371+
title: 'Example Post 1',
372+
content: 'Foo',
373+
comments: {
374+
__typename: 'comment',
375+
nodes: []
376+
},
377+
user: {
378+
__typename: 'user',
379+
id: 2,
380+
name: 'Johnny Imba',
381+
}
382+
}
383+
],
384+
__typename: 'post'
385+
}
386+
}
387+
};
388+
389+
const request = await sendWithMockFetch(response, async () => {
390+
await Post.customQuery('unpublishedPosts', { userId: 2 });
391+
});
392+
393+
expect(request.variables.userId).toEqual(2);
394+
expect(request.query).toEqual(`
395+
query UnpublishedPosts($userId: ID!) {
396+
unpublishedPosts(userId: $userId) {
397+
nodes {
398+
id
399+
content
400+
title
401+
otherId
402+
published
403+
user {
404+
id
405+
name
406+
__typename
407+
}
408+
comments {
409+
nodes {
410+
id
411+
content
412+
subjectId
413+
subjectType
414+
__typename
415+
}
416+
__typename
417+
}
418+
__typename
419+
}
420+
__typename
421+
}
422+
}
423+
`.trim() + "\n");
424+
});
425+
426+
it('via record method sends the correct query to the API', async () => {
427+
const post = Post.find(1);
428+
const response = {
429+
data: {
430+
example: {
431+
__typename: 'post',
432+
id: 1,
433+
otherId: 13548,
434+
published: false,
435+
title: 'Example Post 1',
436+
content: 'Foo',
437+
comments: {
438+
__typename: 'comment',
439+
nodes: []
440+
},
441+
user: {
442+
__typename: 'user',
443+
id: 2,
444+
name: 'Johnny Imba',
445+
}
446+
}
447+
}
448+
};
449+
450+
const request = await sendWithMockFetch(response, async () => {
451+
await post.$customQuery('example', { userId: 2 });
452+
});
453+
454+
expect(request.variables.userId).toEqual(2);
455+
expect(request.variables.id).toEqual(1);
456+
expect(request.query).toEqual(`
457+
query Example($userId: ID!, $id: ID!) {
458+
example(userId: $userId, id: $id) {
459+
id
460+
content
461+
title
462+
otherId
463+
published
464+
user {
465+
id
466+
name
467+
__typename
468+
}
469+
comments {
470+
nodes {
471+
id
472+
content
473+
subjectId
474+
subjectType
475+
__typename
476+
}
477+
__typename
478+
}
479+
__typename
480+
}
481+
}
482+
`.trim() + "\n");
483+
});
484+
});
485+
486+
360487
describe('custom mutation', () => {
361488
it('sends the correct query to the API', async () => {
362489
const post = Post.find(1);

test/unit/QueryBuilder.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ query test {
8484

8585
describe('.buildField', () => {
8686
it('generates query fields for all model fields and relations', () => {
87-
let query = QueryBuilder.buildField(context.getModel('user'), true, { age: 32 });
87+
let query = QueryBuilder.buildField(context.getModel('user'), true, { age: 32 }, undefined, undefined, true);
8888
query = prettify(`query users { ${query} }`).trim();
8989

9090
expect(query).toEqual(`
@@ -106,7 +106,7 @@ query users {
106106
it('generates a complete query for a model', () => {
107107
const args = { title: 'Example Post 1' };
108108

109-
let query = QueryBuilder.buildQuery('query', context.getModel('post'), null, args, true);
109+
let query = QueryBuilder.buildQuery('query', context.getModel('post'), null, args, true, true);
110110
query = prettify(query.loc.source.body);
111111

112112
expect(query).toEqual(`

0 commit comments

Comments
 (0)