diff --git a/src/RelayPublic.js b/src/RelayPublic.js index 06b8fe64f4c02..85e887648b53c 100644 --- a/src/RelayPublic.js +++ b/src/RelayPublic.js @@ -21,6 +21,7 @@ const RelayQL = require('RelayQL'); const RelayRootContainer = require('RelayRootContainer'); const RelayRoute = require('RelayRoute'); const RelayStore = require('RelayStore'); +const RelaySubscription = require('RelaySubscription'); const RelayTaskScheduler = require('RelayTaskScheduler'); const RelayInternals = require('RelayInternals'); @@ -38,6 +39,7 @@ if (typeof global.__REACT_DEVTOOLS_GLOBAL_HOOK__ !== 'undefined') { */ var RelayPublic = { Mutation: RelayMutation, + Subscription: RelaySubscription, PropTypes: RelayPropTypes, QL: RelayQL, RootContainer: RelayRootContainer, diff --git a/src/network-layer/default/RelayDefaultNetworkLayer.js b/src/network-layer/default/RelayDefaultNetworkLayer.js index aec35832b507a..c2eb383d396d1 100644 --- a/src/network-layer/default/RelayDefaultNetworkLayer.js +++ b/src/network-layer/default/RelayDefaultNetworkLayer.js @@ -15,6 +15,8 @@ import type RelayMutationRequest from 'RelayMutationRequest'; import type RelayQueryRequest from 'RelayQueryRequest'; +import type RelaySubscriptionRequest from 'RelaySubscriptionRequest'; +import type {Subscription} from 'RelayTypes'; const fetch = require('fetch'); const fetchWithRetries = require('fetchWithRetries'); @@ -92,6 +94,13 @@ class RelayDefaultNetworkLayer { ))); } + sendSubscription(request: RelaySubscriptionRequest): Subscription { + throw new Error( + 'RelayDefaultNetworkLayer: `sendSubscription` is not implemented in the ' + + 'default network layer. A custom network layer must be injected.' + ); + } + supports(...options: Array): boolean { // Does not support the only defined option, "defer". return false; diff --git a/src/network/RelayNetworkLayer.js b/src/network/RelayNetworkLayer.js index 25b3c97a9f78a..8beeb92887b73 100644 --- a/src/network/RelayNetworkLayer.js +++ b/src/network/RelayNetworkLayer.js @@ -16,8 +16,11 @@ import type RelayMutationRequest from 'RelayMutationRequest'; const RelayProfiler = require('RelayProfiler'); import type RelayQueryRequest from 'RelayQueryRequest'; +import type RelaySubscriptionRequest from 'RelaySubscriptionRequest'; +import type {Subscription} from 'RelayTypes'; const invariant = require('invariant'); +const warning = require('warning'); type NetworkLayer = { sendMutation: (mutationRequest: RelayMutationRequest) => ?Promise; @@ -53,6 +56,31 @@ var RelayNetworkLayer = { } }, + sendSubscription(subscriptionRequest: RelaySubscriptionRequest): Subscription { + const networkLayer = getCurrentNetworkLayer(); + const result = networkLayer.sendSubscription(subscriptionRequest); + + if (result) { + if (typeof result === 'function') { + return { + dispose: result, + }; + } else if (result.dispose && typeof result.dispose === 'function') { + return result; + } else { + warning( + false, + 'NetworkLayer: `sendSubscription` should return a disposable or a ' + + 'function.' + ); + } + } + + return { + dispose() { }, // noop + }; + }, + supports(...options: Array): boolean { var networkLayer = getCurrentNetworkLayer(); return networkLayer.supports(...options); diff --git a/src/network/RelaySubscriptionRequest.js b/src/network/RelaySubscriptionRequest.js new file mode 100644 index 0000000000000..1d59746c27034 --- /dev/null +++ b/src/network/RelaySubscriptionRequest.js @@ -0,0 +1,118 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule RelaySubscriptionRequest + * @typechecks + * @flow + */ + +'use strict'; + +import type {PrintedQuery} from 'RelayInternalTypes'; +import type RelayQuery from 'RelayQuery'; +import type {SubscriptionResult, SubscriptionCallbacks, Variables} from 'RelayTypes'; + +const printRelayQuery = require('printRelayQuery'); + +/** + * @internal + * + * Instances of these are made available via `RelayNetworkLayer.sendSubscription`. + */ +class RelaySubscriptionRequest { + _subscription: RelayQuery.Subscription; + _printedQuery: ?PrintedQuery; + _observer: SubscriptionCallbacks; + + constructor( + subscription: RelayQuery.Subscription, + observer: SubscriptionCallbacks + ) { + this._subscription = subscription; + this._observer = observer; + this._printedQuery = null; + } + + /** + * @public + * + * Gets a string name used to refer to this request for printing debug output. + */ + getDebugName(): string { + return this._subscription.getName(); + } + + /** + * @public + * + * Gets the variables used by the subscription. These variables should be + * serialized and sent in the GraphQL request. + */ + getVariables(): Variables { + var printedQuery = this._printedQuery; + if (!printedQuery) { + printedQuery = printRelayQuery(this._subscription); + this._printedQuery = printedQuery; + } + return printedQuery.variables; + } + + /** + * @public + * + * Gets a string representation of the GraphQL subscription. + */ + getQueryString(): string { + var printedQuery = this._printedQuery; + if (!printedQuery) { + printedQuery = printRelayQuery(this._subscription); + this._printedQuery = printedQuery; + } + return printedQuery.text; + } + + /** + * @public + * + * Called when new event data is received for the subscription. + */ + onNext(result: SubscriptionResult): void { + this._observer.onNext(result); + } + + /** + * @public + * + * Called when there is an error with the subscription. Ends the + * subscription. + */ + onError(err: any): void { + this._observer.onError(err); + } + + /** + * @public + * + * Called when no more data will be provided to the subscriptions. Ends + * the subscription. + */ + onCompleted(): void { + this._observer.onCompleted(); + } + + /** + * @public + * @unstable + */ + getSubscription(): RelayQuery.Subscription { + return this._subscription; + } + +} + +module.exports = RelaySubscriptionRequest; diff --git a/src/store/RelayStore.js b/src/store/RelayStore.js index 5681f4376554d..ddf327100b418 100644 --- a/src/store/RelayStore.js +++ b/src/store/RelayStore.js @@ -15,11 +15,13 @@ const GraphQLFragmentPointer = require('GraphQLFragmentPointer'); import type RelayMutation from 'RelayMutation'; +import type RelaySubscription from 'RelaySubscription'; const RelayMutationTransaction = require('RelayMutationTransaction'); const RelayQuery = require('RelayQuery'); const RelayQueryResultObservable = require('RelayQueryResultObservable'); const RelayStoreData = require('RelayStoreData'); +const createSubscription = require('createSubscription'); const forEachRootCallArg = require('forEachRootCallArg'); const readRelayQueryData = require('readRelayQueryData'); const warning = require('warning'); @@ -28,9 +30,11 @@ import type { Abortable, Observable, RelayMutationTransactionCommitCallbacks, + SubscriptionCallbacks, ReadyStateChangeCallback, StoreReaderData, StoreReaderOptions, + Subscription, } from 'RelayTypes'; import type { @@ -205,6 +209,13 @@ var RelayStore = { ); this.commitUpdate(mutation, callbacks); }, + + subscribe( + subscription: RelaySubscription, + callbacks?: SubscriptionCallbacks + ): Subscription { + return createSubscription(storeData, subscription, callbacks); + }, }; module.exports = RelayStore; diff --git a/src/subscription/RelaySubscription.js b/src/subscription/RelaySubscription.js new file mode 100644 index 0000000000000..092081283d8ba --- /dev/null +++ b/src/subscription/RelaySubscription.js @@ -0,0 +1,285 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule RelaySubscription + * @typechecks + * @flow + */ + +'use strict'; + +import type {ConcreteFragment} from 'ConcreteQuery'; +import type {RelayConcreteNode} from 'RelayQL'; +const RelayFragmentReference = require('RelayFragmentReference'); +import type RelayMetaRoute from 'RelayMetaRoute'; +const RelayStore = require('RelayStore'); +import type { + RelayMutationConfig, + Variables, +} from 'RelayTypes'; + +const buildRQL = require('buildRQL'); +import type {RelayQLFragmentBuilder} from 'buildRQL'; +const forEachObject = require('forEachObject'); +const fromGraphQL = require('fromGraphQL'); +const invariant = require('invariant'); +const warning = require('warning'); + +export type FileMap = {[key: string]: File}; +export type RelayMutationFragments = { + [key: Tk]: RelayQLFragmentBuilder; +}; + +/** + * @public + * + * RelaySubscription is the base class for modeling subscriptions to events. + */ +class RelaySubscription { + static name: $FlowIssue; + /* $FlowIssue(>=0.20.0) #9410317 */ + static fragments: RelayMutationFragments<$Keys>; + static initialVariables: Variables; + static prepareVariables: ?( + prevVariables: Variables, + route: RelayMetaRoute + ) => Variables; + + props: Tp; + _didShowFakeDataWarning: boolean; + + constructor(props: Tp) { + this._didShowFakeDataWarning = false; + this._resolveProps(props); + } + + /** + * Each subscription has a server name which is used by clients to communicate the + * type of subscription that should be executed on the server. + */ + getSubscription(): RelayConcreteNode { + invariant( + false, + '%s: Expected abstract method `getSubscription` to be implemented.', + this.constructor.name + ); + } + + /** + * These configurations are used to generate the query for the subscription to be + * sent to the server and to correctly write the server's response into the + * client store. + * + * Possible configuration types: + * + * - RANGE_ADD provides configuration for adding a new edge to a range. + * { + * type: RelayMutationType.RANGE_ADD; + * parentName: string; + * parentID: string; + * connectionName: string; + * edgeName: string; + * rangeBehaviors: + * {[call: string]: GraphQLMutatorConstants.RANGE_OPERATIONS}; + * } + * where `parentName` is the field in the query that contains the range, + * `parentID` is the DataID of `parentName` in the store, `connectionName` + * is the name of the range, `edgeName` is the name of the key in server + * response that contains the newly created edge, `rangeBehaviors` maps + * stringified representation of calls on the connection to + * GraphQLMutatorConstants.RANGE_OPERATIONS. + * + * - NODE_DELETE provides configuration for deleting a node and the + * corresponding edge from a range. + * { + * type: RelayMutationType.NODE_DELETE; + * parentName: string; + * parentID: string; + * connectionName: string; + * deletedIDFieldName: string; + * } + * where `parentName`, `parentID` and `connectionName` refer to the same + * things as in RANGE_ADD, `deletedIDFieldName` is the name of the key in + * the server response that contains the DataID of the deleted node. + * + * - RANGE_DELETE provides configuration for deleting an edge from a range + * but doesn't delete the node. + * { + * type: RelayMutationType.RANGE_DELETE; + * parentName: string; + * parentID: string; + * connectionName: string; + * deletedIDFieldName: string; + * pathToConnection: Array; + * } + * where `parentName`, `parentID`, `connectionName` and + * `deletedIDFieldName` refer to the same things as in NODE_DELETE, + * `pathToConnection` provides a path from `parentName` to + * `connectionName`. + * + */ + getConfigs(): Array { + invariant( + false, + '%s: Expected abstract method `getConfigs` to be implemented.', + this.constructor.name + ); + } + + /** + * These variables form the "input" to the subscription query sent to the server. + */ + getVariables(): {[name: string]: mixed} { + invariant( + false, + '%s: Expected abstract method `getVariables` to be implemented.', + this.constructor.name + ); + } + + _resolveProps(props: Tp): void { + const fragments = this.constructor.fragments; + const initialVariables = this.constructor.initialVariables || {}; + + const resolvedProps = {...props}; + forEachObject(fragments, (fragmentBuilder, fragmentName) => { + var propValue = props[fragmentName]; + warning( + propValue !== undefined, + 'RelaySubscription: Expected data for fragment `%s` to be supplied to ' + + '`%s` as a prop. Pass an explicit `null` if this is intentional.', + fragmentName, + this.constructor.name + ); + + if (!propValue) { + return; + } + + var fragment = fromGraphQL.Fragment(buildSubscriptionFragment( + this.constructor.name, + fragmentName, + fragmentBuilder, + initialVariables + )); + var fragmentHash = fragment.getConcreteNodeHash(); + + if (fragment.isPlural()) { + invariant( + Array.isArray(propValue), + 'RelaySubscription: Invalid prop `%s` supplied to `%s`, expected an ' + + 'array of records because the corresponding fragment is plural.', + fragmentName, + this.constructor.name + ); + var dataIDs = propValue.reduce((acc, item, ii) => { + var eachFragmentPointer = item[fragmentHash]; + invariant( + eachFragmentPointer, + 'RelaySubscription: Invalid prop `%s` supplied to `%s`, ' + + 'expected element at index %s to have query data.', + fragmentName, + this.constructor.name, + ii + ); + return acc.concat(eachFragmentPointer.getDataIDs()); + }, []); + + resolvedProps[fragmentName] = RelayStore.readAll(fragment, dataIDs); + } else { + invariant( + !Array.isArray(propValue), + 'RelaySubscription: Invalid prop `%s` supplied to `%s`, expected a ' + + 'single record because the corresponding fragment is not plural.', + fragmentName, + this.constructor.name + ); + var fragmentPointer = propValue[fragmentHash]; + if (fragmentPointer) { + var dataID = fragmentPointer.getDataID(); + resolvedProps[fragmentName] = RelayStore.read(fragment, dataID); + } else { + if (__DEV__) { + if (!this._didShowFakeDataWarning) { + this._didShowFakeDataWarning = true; + warning( + false, + 'RelaySubscription: Expected prop `%s` supplied to `%s` to ' + + 'be data fetched by Relay. This is likely an error unless ' + + 'you are purposely passing in mock data that conforms to ' + + 'the shape of this subscription\'s fragment.', + fragmentName, + this.constructor.name + ); + } + } + } + } + }); + this.props = resolvedProps; + } + + static getFragment( + fragmentName: $Keys, + variableMapping?: Variables + ): RelayFragmentReference { + // TODO: Unify fragment API for containers and mutations, #7860172. + var fragments = this.fragments; + var fragmentBuilder = fragments[fragmentName]; + if (!fragmentBuilder) { + invariant( + false, + '%s.getFragment(): `%s` is not a valid fragment name. Available ' + + 'fragments names: %s', + this.name, + fragmentName, + Object.keys(fragments).map(name => '`' + name + '`').join(', ') + ); + } + + const initialVariables = this.initialVariables || {}; + var prepareVariables = this.prepareVariables; + + return RelayFragmentReference.createForContainer( + () => buildSubscriptionFragment( + this.name, + fragmentName, + fragmentBuilder, + initialVariables + ), + initialVariables, + variableMapping, + prepareVariables + ); + } +} + +/** + * Wrapper around `buildRQL.Fragment` with contextual error messages. + */ +function buildSubscriptionFragment( + subscriptionName: string, + fragmentName: string, + fragmentBuilder: RelayQLFragmentBuilder, + variables: Variables +): ConcreteFragment { + var fragment = buildRQL.Fragment( + fragmentBuilder, + variables + ); + invariant( + fragment, + 'Relay.QL defined on subscription `%s` named `%s` is not a valid fragment. ' + + 'A typical fragment is defined using: Relay.QL`fragment on Type {...}`', + subscriptionName, + fragmentName + ); + return fragment; +} + +module.exports = RelaySubscription; diff --git a/src/subscription/RelaySubscriptionObserver.js b/src/subscription/RelaySubscriptionObserver.js new file mode 100644 index 0000000000000..b9251db3d227d --- /dev/null +++ b/src/subscription/RelaySubscriptionObserver.js @@ -0,0 +1,270 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule RelaySubscriptionObserver + * @typechecks + * @flow + */ + +'use strict'; + +import type {ConcreteSubscription} from 'ConcreteQuery'; +import type {ClientSubscriptionID} from 'RelayInternalTypes'; +import type RelayQuery from 'RelayQuery'; +import type RelaySubscription from 'RelaySubscription'; +import type RelayStoreData from 'RelayStoreData'; +import type { + RelayMutationConfig, + Subscription, + SubscriptionCallbacks, + SubscriptionResult, + Variables, +} from 'RelayTypes'; + +const QueryBuilder = require('QueryBuilder'); +const RelayConnectionInterface = require('RelayConnectionInterface'); + +const base62 = require('base62'); +const buildSubscriptionQuery = require('buildSubscriptionQuery'); +const invariant = require('invariant'); + +const {CLIENT_SUBSCRIPTION_ID} = RelayConnectionInterface; + + +let subscriptionIDCounter = 0; + +/** + * AbstractObserver is a base class which implements the Rx specific parts of + * the subscription. It is only used by RelaySubscriptionObserver but is a + * separate class purely to make it easier to see what logic is abstract + * Rx behavior and what is speific to implementing Relay subscriptions. + * + * In RxJS parlance this is an AutoDetachObserver and SingleAssignmentDisposable. + * The significance of this is that the subscription is disposed when the + * observable is complete (onError / onCompleted). + * + * For a release this abstract class could merged directly into + * RelaySubscriptionObserver and not a separate class. + */ +class AbstractObserver { + _active: boolean; + _disposed: boolean; + _disposable: ?Subscription; + + + constructor() { + this._active = true; + this._disposed = false; + this._disposable = null; + } + + next(data) { + invariant( + false, + '%s: Expected abstract method `next` to be implemented.', + this.constructor.name + ); + } + + error(error) { + invariant( + false, + '%s: Expected abstract method `error` to be implemented.', + this.constructor.name + ); + } + + completed() { + invariant( + false, + '%s: Expected abstract method `completed` to be implemented.', + this.constructor.name + ); + } + + onNext(data) { + if (this._active) { + try { + this.next(data); + } catch (e) { + // TODO: are these the semantics we want? A callback error closes the + // subscription? + this.dispose(); + throw e; + } + } + } + + onError(error) { + if (this._active) { + this._active = false; + try { + this.error(error); + } finally { + this.dispose(); + } + } + } + + onCompleted() { + if (this._active) { + this._active = false; + try { + this.completed(); + } finally { + this.dispose(); + } + } + } + + dispose(): void { + this._active = false; + if (!this._disposed) { + this._disposed = true; + if (this._disposable) { + this._disposable.dispose(); + } + } + } + + setDisposable(disposable: Subscription): void { + invariant( + !this._disposable, + '%s: attempting to set disposable more than once', + this.constructor.name + ); + + this._disposable = disposable; + if (this._disposed) { + this._disposable.dispose(); + } + } + + // not sure if necessary, just hides other methods + asObserver(): SubscriptionCallbacks { + return { + onNext: (data) => this.onNext(data), + onError: (error) => this.onError(error), + onCompleted: () => this.onCompleted(), + }; + } + + asDisposable(): Subscription { + return { + dispose: () => this.dispose(), + }; + } + +} + +/** + * RelaySubscriptionObserver is created when a user subscribes an instance of + * RelaySubscription. It handles the creation of the RelayQuery.Subscription + * as well as writing the subscription payload to the RelayStoreData. + */ +class RelaySubscriptionObserver extends AbstractObserver { + id: ClientSubscriptionID; + + _callbacks: ?SubscriptionCallbacks; + _storeData: RelayStoreData; + _subscription: RelaySubscription; + + _inputVariable: Variables; + _query: RelayQuery.Subscription; + _subscriptionNode: ConcreteSubscription; + _configs: Array; + + + constructor( + storeData: RelayStoreData, + subscription: RelaySubscription, + callbacks?: SubscriptionCallbacks) { + + super(); + + this.id = base62(subscriptionIDCounter++); + + this._storeData = storeData; + this._subscription = subscription; + this._callbacks = callbacks; + } + + getQuery(): RelayQuery.Subscription { + if (!this._query) { + this._query = buildSubscriptionQuery( + this.getSubscriptionNode(), + this.getInputVariable(), + this.getConfigs() + ); + } + return this._query; + } + + getInputVariable(): Variables { + if (!this._inputVariable) { + this._inputVariable = { + ...this._subscription.getVariables(), + [CLIENT_SUBSCRIPTION_ID]: this.id, + }; + } + return this._inputVariable; + } + + getSubscriptionNode(): ConcreteSubscription { + if (!this._subscriptionNode) { + const subscriptionNode = QueryBuilder.getSubscription(this._subscription.getSubscription()); + invariant( + subscriptionNode, + 'RelaySubscription: Expected `getSubscription` to return a subscription created ' + + 'with Relay.QL`subscription { ... }`.' + ); + this._subscriptionNode = subscriptionNode; + } + return this._subscriptionNode; + } + + getConfigs(): Array { + if (!this._configs) { + this._configs = this._subscription.getConfigs(); + } + return this._configs; + } + + next(data: SubscriptionResult) { + const query = this.getQuery(); + const payload = data.response[query.getCall().name]; + + this._storeData.handleUpdatePayload( + query, + payload, + { + configs: this.getConfigs(), + isOptimisticUpdate: false, + } + ); + + if (this._callbacks && this._callbacks.onNext) { + this._callbacks.onNext(payload); + } + } + + error(error: any) { + if (this._callbacks && this._callbacks.onError) { + this._callbacks.onError(error); + } + } + + completed() { + if (this._callbacks && this._callbacks.onCompleted) { + this._callbacks.onCompleted(); + } + } + +} + +module.exports = RelaySubscriptionObserver; diff --git a/src/subscription/buildSubscriptionQuery.js b/src/subscription/buildSubscriptionQuery.js new file mode 100644 index 0000000000000..60ce5223ff912 --- /dev/null +++ b/src/subscription/buildSubscriptionQuery.js @@ -0,0 +1,190 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule buildSubscriptionQuery + * @typechecks + * @flow + */ + +'use strict'; + +import type {ConcreteSubscription} from 'ConcreteQuery'; +import type { + RelayMutationConfig, + Variables, +} from 'RelayTypes'; + +const RelayConnectionInterface = require('RelayConnectionInterface'); +const RelayNodeInterface = require('RelayNodeInterface'); +const RelayMetaRoute = require('RelayMetaRoute'); +const RelayMutationType = require('RelayMutationType'); +const RelayQuery = require('RelayQuery'); + +const invariant = require('invariant'); +const warning = require('warning'); + +const {CLIENT_SUBSCRIPTION_ID} = RelayConnectionInterface; +const {TYPENAME} = RelayNodeInterface; + +/** + * `buildSubscriptionQuery` takes an AST node for a subscription and + * creates a RelayQuery.Subscription. The majority of the work is handled by + * `RelayQuery.Subscription#create` with a few fields appended to the query + * due to the `RelayMutationConfig`. This logic may not be required and instead + * the user could be given warnings / errors to correct their subscription query + * to match their configuration. + */ +function buildSubscriptionQuery( + node: ConcreteSubscription, + input: Variables, + configs: Array): RelayQuery.Subscription { + + let query = RelayQuery.Subscription.create( + node, + RelayMetaRoute.get('$RelaySubscriptionObserver'), + {input} + ); + + // append `CLIENT_SUBSCRIPTION_ID` to the payload no matter what + // I'm still not certain why this is required but `Relay.QL` requires it as + // an input to the subscription field so might as well make sure its in the + // payload. + let nextChildren = query.getChildren().concat( + RelayQuery.Field.build({ + fieldName: CLIENT_SUBSCRIPTION_ID, + type: 'String', + metadata: {isRequisite:true}, + }) + ); + + // can't use a reduce for some reason, flow is unhappy + configs.forEach(config => { + switch (config.type) { + case RelayMutationType.RANGE_ADD: + nextChildren = updateEdgeFieldForInsertion(nextChildren, config); + break; + + case RelayMutationType.RANGE_DELETE: + case RelayMutationType.NODE_DELETE: + nextChildren = nextChildren.concat(RelayQuery.Field.build({ + fieldName: config.deletedIDFieldName, + type: 'String', + })); + break; + + case RelayMutationType.REQUIRED_CHILDREN: + warning( + false, + '`REQUIRED_CHILDREN` is not applicable to subscriptions, place ' + + 'any required children in the subscription query itself.' + ); + break; + case RelayMutationType.FIELDS_CHANGE: + warning( + false, + '`FIELDS_CHANGE` is not applicable to subscriptions, any ' + + 'fields present in the subscription query will be changed.' + ); + break; + } + }); + + query = query.clone(nextChildren); + + invariant( + query instanceof RelayQuery.Subscription, + 'RelaySubscriptionObserver: Expected a subscription.' + ); + + return query; +} + +/* + * For mutation configs of `RANGE_ADD` we need to insert the `__typename` field + * on the edge to be added, configured via `edgeName`. If this is not done + * the query writer emits warnings it does not know the type of the edge. + */ +function updateEdgeFieldForInsertion(children, config) { + let hasEdgeField = false; + + // we need to walk fragments in case the `edge` is not a direct child. + // this may be unnecessary and instead we just warn the user to put it in + // the direct selection set. + // + // e.g. + // + // subscription { + // addTodoSubscribe { ... on AddTodoSubscribePayload { todoEdge } } + // } + // + // vs. + // + // subscription { + // addTodoSubscribe { todoEdge } + // } + // + const nextChildren = children.map(child => mapFields(child, field => { + if (field.getSchemaName() === config.edgeName) { + hasEdgeField = true; + return addField(field, RelayQuery.Field.build({ + fieldName: TYPENAME, + type: 'String', + })); + } else { + return field; + } + })); + + invariant( + hasEdgeField, + 'RelaySubscription: query does not contain edge `%s`.', + config.edgeName + ); + + return nextChildren; +} + +function addField( + parent: RelayQuery.Field, + child: RelayQuery.Field): RelayQuery.Field { + + const newField = parent.clone(parent.getChildren().concat(child)); + invariant( + newField instanceof RelayQuery.Field, + 'buildSubscriptionQuery: Expected a field.' + ); + return newField; +} + +/** + * maps a function against a node's child fields, including fields in child + * fragments + */ +function mapFields( + node: RelayQuery.Node, + fn: (value: RelayQuery.Field) => RelayQuery.Field +): RelayQuery.Node { + if (node instanceof RelayQuery.Field) { + return fn(node); + } else if (node instanceof RelayQuery.Fragment) { + // TODO: can avoid the clone if no child changes + const newFragment = node.clone(node.getChildren().map(child => mapFields(child, fn))); + invariant( + newFragment instanceof RelayQuery.Fragment, + 'buildSubscriptionQuery: Expected a fragment.' + ); + return newFragment; + } else { + invariant( + false, + 'buildSubscriptionQuery: Expected a field or a fragment.' + ); + } +} + +module.exports = buildSubscriptionQuery; diff --git a/src/subscription/createSubscription.js b/src/subscription/createSubscription.js new file mode 100644 index 0000000000000..3d6b8a17e94b4 --- /dev/null +++ b/src/subscription/createSubscription.js @@ -0,0 +1,60 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule createSubscription + * @typechecks + * @flow + */ + +import type RelaySubscription from 'RelaySubscription'; +import type RelayStoreData from 'RelayStoreData'; + +const RelayNetworkLayer = require('RelayNetworkLayer'); +const RelaySubscriptionObserver = require('RelaySubscriptionObserver'); +const RelaySubscriptionRequest = require('RelaySubscriptionRequest'); + +import type { + SubscriptionCallbacks, + Subscription, +} from 'RelayTypes'; + +/** + * `createSubscription` activates a user defined subscription by sending it + * to the network layer. + * + * This could be put on some kind of `SubscriptionManager` object if there + * were a requirement to be able to view all active subscriptions or dispose + * all active subscriptions. The `RelayStoreData` would then have a single + * instance of this. + */ +function createSubscription( + storeData: RelayStoreData, + subscription: RelaySubscription, + callbacks?: SubscriptionCallbacks +): Subscription { + + const observer = new RelaySubscriptionObserver( + storeData, + subscription, + callbacks + ); + + const request = new RelaySubscriptionRequest( + observer.getQuery(), + observer.asObserver() + ); + + // the network layer returns a disposable which will clean up / close any + // resources associated with the subscription + const disposable = RelayNetworkLayer.sendSubscription(request); + observer.setDisposable(disposable); + + return observer.asDisposable(); +} + +module.exports = createSubscription; diff --git a/src/tools/RelayInternalTypes.js b/src/tools/RelayInternalTypes.js index 249547b8b2a88..5727965021060 100644 --- a/src/tools/RelayInternalTypes.js +++ b/src/tools/RelayInternalTypes.js @@ -32,6 +32,7 @@ export type Call = { export type CallValue = mixed; export type ClientMutationID = string; +export type ClientSubscriptionID = string; export type DataID = string; diff --git a/src/tools/RelayTypes.js b/src/tools/RelayTypes.js index ba4dd6f7bf7d6..f6d394291722d 100644 --- a/src/tools/RelayTypes.js +++ b/src/tools/RelayTypes.js @@ -214,6 +214,9 @@ export type QueryResult = { ref_params?: ?{[name: string]: mixed}; response: Object; }; +export type SubscriptionResult = { + response: Object; +}; // Utility export type Abortable = { diff --git a/src/traversal/printRelayOSSQuery.js b/src/traversal/printRelayOSSQuery.js index a751caabbc047..bc5dd0960b109 100644 --- a/src/traversal/printRelayOSSQuery.js +++ b/src/traversal/printRelayOSSQuery.js @@ -55,6 +55,8 @@ function printRelayOSSQuery(node: RelayQuery.Node): PrintedQuery { queryText = printRoot(node, printerState); } else if (node instanceof RelayQuery.Mutation) { queryText = printMutation(node, printerState); + } else if (node instanceof RelayQuery.Subscription) { + queryText = printSubscription(node, printerState); } else { // NOTE: `node` shouldn't be a field or fragment except for debugging. There // is no guarantee that it would be a valid server request if printed. @@ -117,6 +119,21 @@ function printRoot( function printMutation( node: RelayQuery.Mutation, printerState: PrinterState +): string { + return printOperation('mutation', node, printerState); +} + +function printSubscription( + node: RelayQuery.Subscription, + printerState: PrinterState +): string { + return printOperation('subscription', node, printerState); +} + +function printOperation( + operationName: string, + node: RelayQuery.Operation, + printerState: PrinterState ): string { const call = node.getCall(); const inputString = printArgument( @@ -127,16 +144,17 @@ function printMutation( ); invariant( inputString, - 'printRelayOSSQuery(): Expected mutation `%s` to have a value for `%s`.', + 'printRelayOSSQuery(): Expected %s `%s` to have a value for `%s`.', + operationName, node.getName(), node.getCallVariableName() ); // Note: children must be traversed before printing variable definitions const children = printChildren(node, printerState); - const mutationString = node.getName() + printVariableDefinitions(printerState); + const operationString = node.getName() + printVariableDefinitions(printerState); const fieldName = call.name + '(' + inputString + ')'; - return 'mutation ' + mutationString + '{' + fieldName + children + '}'; + return operationName + ' ' + operationString + '{' + fieldName + children + '}'; } function printVariableDefinitions(printerState: PrinterState): string {