Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add option to merge variables from debounced calls #11

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
"description": "An Apollo Link to debounce requests",
"dependencies": {
"apollo-link": "^1.2.2",
"lodash.merge": "^4.6.2",
"zen-observable-ts": "^0.8.9"
},
"devDependencies": {
"@types/graphql": "^0.13.0",
"@types/jest": "^23.1.4",
"@types/lodash.merge": "^4.6.6",
"@types/node": "10.1.2",
"codecov": "^3.0.3",
"graphql": "^0.13.2",
Expand Down
68 changes: 67 additions & 1 deletion src/DebounceLink.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import DebounceLink from './DebounceLink';
import DebounceLink, { DebounceOpts } from './DebounceLink';
import {
ObservableEvent,
TestSequenceLink,
Expand All @@ -15,6 +15,7 @@ import {
} from 'graphql';

import gql from 'graphql-tag';
const merge = require('lodash.merge');

describe('DebounceLink', () => {
let link: ApolloLink;
Expand Down Expand Up @@ -56,6 +57,18 @@ describe('DebounceLink', () => {
};
}

function makeVariableOp(debounceKey: string, variables: Record<string, any>, debounceOpts: DebounceOpts = { mergeVariables: true }): GraphQLRequest {
return {
query: gql`{hello}`,
variables,
context: {
debounceKey,
debounceOpts,
testSequence: makeSimpleSequence(testResponse)
}
};
}

function getTestSubscriber(observedSequence: ObservableEvent[]) {
return {
next(value: ExecutionResult) {
Expand Down Expand Up @@ -412,4 +425,57 @@ describe('DebounceLink', () => {

s1.unsubscribe();
});
describe('with variables', () => {
let variables;
let mergeVariables;

const createAndQueueOps = (contextKey = 'key1') => {
const variableOps = variables.map(v => makeVariableOp(contextKey, v, { mergeVariables }));
const subscriber = getTestSubscriber([]);
variableOps.forEach(vo => execute(link, vo).subscribe(subscriber));
};

const subject = () => {
createAndQueueOps();
jest.runTimersToTime(DEBOUNCE_TIMEOUT + 1);
};

beforeEach(() => {
variables = [{ a: 5, b: { c: 6 } }, { b: { d: 4 } }, { e: 3 }];
mergeVariables = true;
});
it('merges the operation variables key with the mergeVariables opt set', () => {
subject();

expect(testLink.operations.length).toEqual(1);
expect(testLink.operations[0].variables).toEqual(variables.reduce(merge, {}));
});
it('does not merge the operation variables when the mergeVariables opt is false', () => {
mergeVariables = false;
subject();

expect(testLink.operations.length).toEqual(1);
expect(testLink.operations[0].variables).toEqual(variables.slice(-1)[0]);
});
it('merges only variables within an interval', () => {
subject();

variables = [{ d: 5 }];
subject();

expect(testLink.operations.length).toEqual(2);
expect(testLink.operations[1].variables).toEqual(variables[0]);
});
it('merges variables with separate debounce keys separately', () => {
createAndQueueOps('key2');
const mergedVariables = variables.reduce(merge, {});
variables = [{ d: 5 }];
subject();

expect(testLink.operations.length).toEqual(2);
expect(testLink.operations[0].variables).toEqual(mergedVariables);
expect(testLink.operations[1].variables).toEqual(variables.reduce(merge, {}));
});
});
});
})
103 changes: 73 additions & 30 deletions src/DebounceLink.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import {
ApolloLink,
FetchResult,
Operation,
NextLink,
} from 'apollo-link';
import { ApolloLink, FetchResult, Operation, NextLink, GraphQLRequest, createOperation } from 'apollo-link';
import { Observable, Observer } from 'zen-observable-ts';
const merge = require('lodash.merge');

interface OperationQueueEntry {
operation: Operation;
Expand All @@ -21,17 +17,29 @@ interface RunningSubscriptions {
}

interface DebounceMetadata {
// tslint:disable-next-line no-any
timeout: any;
runningSubscriptions: RunningSubscriptions;
queuedObservers: Observer<FetchResult>[];
currentGroupId: number;
lastRequest?: { operation: Operation, forward: NextLink };
// tslint:disable-next-line no-any
timeout: any;
runningSubscriptions: RunningSubscriptions;
queuedObservers: Observer<FetchResult>[];
currentGroupId: number;
lastRequest?: { operation: Operation; forward: NextLink };
queuedVariables?: Record<string, any>[];
}

interface ContextOptions {
debounceKey: string;
debounceTimeout: number;
debounceOpts: DebounceOpts;
}

export interface DebounceOpts {
mergeVariables: Boolean;
}

const defaultOpts = { mergeVariables: false };

function mutateOperation(ctx: Record<string, any>, base: Operation): Operation {
return createOperation(ctx, base);
}

export default class DebounceLink extends ApolloLink {
Expand All @@ -49,13 +57,20 @@ export default class DebounceLink extends ApolloLink {
}

public request(operation: Operation, forward: NextLink) {
const { debounceKey, debounceTimeout } = operation.getContext();
const {
debounceKey,
debounceTimeout,
debounceOpts
} = operation.getContext();

if (!debounceKey) {
return forward(operation);
}
return new Observable(observer => {
const debounceGroupId = this.enqueueRequest({ debounceKey, debounceTimeout }, { operation, forward, observer });
const debounceGroupId = this.enqueueRequest(
{ debounceKey, debounceTimeout, debounceOpts },
{ operation, forward, observer }
);
return () => {
this.unsubscribe(debounceKey, debounceGroupId, observer);
};
Expand All @@ -68,26 +83,38 @@ export default class DebounceLink extends ApolloLink {
this.debounceInfo[debounceKey] = {
runningSubscriptions: {},
queuedObservers: [],
queuedVariables: [],
// NOTE(helfer): In theory we could run out of numbers for groupId, but it's not a realistic use-case.
// If the debouncer fired once every ms, it would take about 300,000 years to run out of safe integers.
currentGroupId: 0,
timeout: undefined,
lastRequest: undefined,
lastRequest: undefined
};
return this.debounceInfo[debounceKey];
}

// Add a request to the debounce queue
private enqueueRequest({ debounceKey, debounceTimeout }: ContextOptions, { operation, forward, observer }: OperationQueueEntry) {
const dbi = this.debounceInfo[debounceKey] || this.setupDebounceInfo(debounceKey);
private enqueueRequest(
{ debounceKey, debounceTimeout, debounceOpts }: ContextOptions,
{ operation, forward, observer }: OperationQueueEntry
) {
const mergedOpts = { ...defaultOpts, ...debounceOpts };
const dbi =
this.debounceInfo[debounceKey] || this.setupDebounceInfo(debounceKey);

dbi.queuedObservers.push(observer);
dbi.lastRequest = { operation, forward };
if (mergedOpts.mergeVariables) {
dbi.queuedVariables.push(operation.variables);
}
if (dbi.timeout) {
clearTimeout(dbi.timeout);
}

dbi.timeout = setTimeout(() => this.flush(debounceKey), debounceTimeout || this.defaultDelay);
dbi.timeout = setTimeout(
() => this.flush(debounceKey),
debounceTimeout || this.defaultDelay
);
return dbi.currentGroupId;
}

Expand All @@ -103,45 +130,62 @@ export default class DebounceLink extends ApolloLink {
clearTimeout(dbi.timeout);
}

const noRunningSubscriptions = Object.keys(dbi.runningSubscriptions).length === 0;
const noRunningSubscriptions =
Object.keys(dbi.runningSubscriptions).length === 0;
const noQueuedObservers = dbi.queuedObservers.length === 0;
if (noRunningSubscriptions && noQueuedObservers) {
delete this.debounceInfo[debounceKey];
}
}
};

// flush the currently queued requests
private flush(debounceKey: string) {
const dbi = this.debounceInfo[debounceKey];
if (dbi.queuedObservers.length === 0 || typeof dbi.lastRequest === 'undefined') {
if (
dbi.queuedObservers.length === 0 ||
typeof dbi.lastRequest === 'undefined'
) {
// The first should never happen, the second is a type guard
return;
}
const { operation, forward } = dbi.lastRequest;
const mergedVariables = [...dbi.queuedVariables, operation.variables].reduce(
merge,
{}
) as Operation;
const mergedOperation = mutateOperation(operation.getContext(), { ...operation, variables: mergedVariables });
const currentObservers = [...dbi.queuedObservers];
const groupId = dbi.currentGroupId;
const sub = forward(operation).subscribe({
const sub = forward(mergedOperation).subscribe({
next: (v: FetchResult) => {
currentObservers.forEach(observer => observer.next && observer.next(v));
},
error: (e: Error) => {
currentObservers.forEach(observer => observer.error && observer.error(e));
currentObservers.forEach(
observer => observer.error && observer.error(e)
);
this.cleanup(debounceKey, groupId);
},
complete: () => {
currentObservers.forEach(observer => observer.complete && observer.complete());
currentObservers.forEach(
observer => observer.complete && observer.complete()
);
this.cleanup(debounceKey, groupId);
},
}
});
dbi.runningSubscriptions[dbi.currentGroupId] = {
subscription: sub,
observers: currentObservers,
observers: currentObservers
};
dbi.queuedObservers = [];
dbi.currentGroupId++;
}

private unsubscribe = (debounceKey: string, debounceGroupId: number, observer: Observer<FetchResult>) => {
private unsubscribe = (
debounceKey: string,
debounceGroupId: number,
observer: Observer<FetchResult>
) => {
// NOTE(helfer): This breaks if the same observer is
// used for multiple subscriptions to the same observable.
// To be fair, I think all Apollo Links will currently execute the request
Expand All @@ -152,7 +196,7 @@ export default class DebounceLink extends ApolloLink {
// TODO(helfer): Why do subscribers seem to unsubscribe when the subscription completes?
// Isn't that unnecessary?

const isNotObserver = (obs: any) => obs !== observer;
const isNotObserver = (obs: any) => obs !== observer;

const dbi = this.debounceInfo[debounceKey];

Expand All @@ -161,7 +205,6 @@ export default class DebounceLink extends ApolloLink {
return;
}


// if this observer is in the queue that hasn't been executed yet, remove it
if (debounceGroupId === dbi.currentGroupId) {
dbi.queuedObservers = dbi.queuedObservers.filter(isNotObserver);
Expand All @@ -183,5 +226,5 @@ export default class DebounceLink extends ApolloLink {
this.cleanup(debounceKey, debounceGroupId);
}
}
}
};
}
4 changes: 2 additions & 2 deletions src/TestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class TestLink extends ApolloLink {
this.operations = [];
}

public request (operation: Operation) {
public request(operation: Operation) {
this.operations.push(operation);
// TODO(helfer): Throw an error if neither testError nor testResponse is defined
return new Observable(observer => {
Expand All @@ -69,7 +69,7 @@ export class TestSequenceLink extends ApolloLink {
this.operations = [];
}

public request (operation: Operation, forward: NextLink) {
public request(operation: Operation, forward: NextLink) {
if (!operation.getContext().testSequence) {
return forward(operation);
}
Expand Down
4 changes: 3 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"module": "commonjs",
"moduleResolution": "node",
"noImplicitAny": true,
"lib": ["esnext"],
"lib": [
"esnext"
],
"removeComments": true,
"preserveConstEnums": true,
"outDir": "build/dist",
Expand Down
17 changes: 17 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@
version "23.1.4"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.1.4.tgz#71e1e2d08b1db742f479ee2795536ebc999a2419"

"@types/lodash.merge@^4.6.6":
version "4.6.6"
resolved "https://registry.yarnpkg.com/@types/lodash.merge/-/lodash.merge-4.6.6.tgz#b84b403c1d31bc42d51772d1cd5557fa008cd3d6"
integrity sha512-IB90krzMf7YpfgP3u/EvZEdXVvm4e3gJbUvh5ieuI+o+XqiNEt6fCzqNRaiLlPVScLI59RxIGZMQ3+Ko/DJ8vQ==
dependencies:
"@types/lodash" "*"

"@types/lodash@*":
version "4.14.149"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440"
integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==

"@types/[email protected]":
version "10.1.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.1.2.tgz#1b928a0baa408fc8ae3ac012cc81375addc147c6"
Expand Down Expand Up @@ -4429,6 +4441,11 @@ lodash.memoize@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"

lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==

lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
Expand Down