File

src/app/data-dgql/query.ts

Description

A piece of observable data.

DataQuery is a stateful interface for interacting with an API object. Instead of calling a function to make an API request, DataQuery lets you declare the ID (like an endpoint) and request parameters (in #params) of the data you want, and will automatically load the data when needed. Data can then be accessed synchronously with the #current property.

Upon adding a subscriber with #subscribe, data will loaded from the API and stored in the cache. Subsequent viewers can then immediately access the cached data.

  • To check if data is loaded, use #hasData, and to check if data is still loading, use #loading.
  • To (re-)load the data from the API, use #load. This happens automatically upon subscription after a sufficient delay (see debounce time constants).
  • To add a subscriber without triggering this behavior, use #subscribeLazy, which will only make an API request if the data is not cached.
  • To invalidate (i.e. delete) the cached data, use #invalidate.
  • If you only need the data right now and don't want to deal with subscriptions, use #dataAsPromise to access it as a promise that will either return cached data or load new data.

When done using a DataQuery subscription, it must be manually destroyed by calling sub.unsubscribe() on the Subscription object returned by subscribe, as it may leak memory otherwise.

See DataNode and DataList for the two main types of data that use DataQuery.

Extends

Observable

Index

Properties
Methods
Accessors

Properties

id
Type : I

The ID of this data.

interactive
Default value : false

If true, will prolong debounce time a bit.

loading
Default value : false

Methods

dataAsPromise
dataAsPromise()

Returns the data as a promise, without having to create a subscription.

If cached data is available, this will return the data immediately; otherwise, this will load the data with an API request.

Example

const node = dataService.getNode(someNodeId);
node.dataAsPromise().then(data => {
  console.log('node data:', data);
}).catch(error => console.error('oh no'));
Returns : Promise<T>
hydrateRaw
hydrateRaw(preparedData: Promise)

Use when data has not yet been loaded but is available from elsewhere.

Parameters :
Name Type Optional
preparedData Promise<R> No
Returns : void
invalidate
invalidate()

Deletes current data.

Returns : void
load
load()

Loads data.

Returns : void
loadDebounced
loadDebounced(interactive)

Loads data after a short delay. Will debounce.

Parameters :
Name Optional Default value
interactive No this.interactive
Returns : void
loadIfNeeded
loadIfNeeded()

Will load data if it's stale or not present.

Returns : void
subscribeLazy
subscribeLazy(...args: any[])

Will subscribe to the data, but not cause a reload unless there is no data.

Parameters :
Name Type Optional Description
args any[] No

passed verbatim to #subscribe

Returns : Subscription

Accessors

hasData
gethasData()

Returns true if data is currently available.

Returns : boolean
current
getcurrent()

The currently loaded data.

Returns : T
params
getparams()

Parameters that will be passed to the request. Changing this property will automatically trigger a load.

Returns : P | undefined
setparams(p: P)
Parameters :
Name Type Optional
p P No
Returns : void
subscriberCount
getsubscriberCount()

Returns the number of subscribers for this data.

Returns : number
import {Observable, Subscriber, Subscription} from 'rxjs';
import {decodeNodeId, encodeNodeId, ListId, ListParams, NodeId, NodeIdEnc, nodeTypeFromTypename} from './id';
import {QueriesService} from './queries/queries.service';
import {ListResult, queryList, queryNode} from './load';
import {PageInfo} from '../../generated/graphql-dgql';

/** How long {@link DataQuery} will wait to debounce requests until actually sending a request, in milliseconds. */
const CACHE_FAST_DEBOUNCE_TIME_MS = 200;
/** How long {@link DataQuery} will wait to debounce requests, if the {@link DataQuery#interactive} flag is set, in milliseconds. */
const CACHE_INTERACTIVE_DEBOUNCE_TIME_MS = 500;
/** Number of milliseconds beyond which cached data will be considered stale, and will be reloaded if a new subscriber is added. */
const CACHE_STALE_TIME_MS = 5000;

/**
 * A piece of observable data.
 *
 * DataQuery is a stateful interface for interacting with an API object.
 * Instead of calling a function to make an API request, DataQuery lets you declare the ID (like an endpoint) and
 * request parameters (in {@link #params}) of the data you want, and will automatically load the data when needed.
 * Data can then be accessed synchronously with the {@link #current} property.
 *
 * Upon adding a subscriber with [#subscribe]{@link Observable#subscribe}, data will loaded from the API and stored in the
 * cache. Subsequent viewers can then immediately access the cached data.
 *
 * - To check if data is loaded, use {@link #hasData},
 *   and to check if data is still loading, use {@link #loading}.
 * - To (re-)load the data from the API, use {@link #load}.
 *   This happens automatically upon subscription after a sufficient delay (see debounce time constants).
 * - To add a subscriber without triggering this behavior, use {@link #subscribeLazy}, which will
 *   only make an API request if the data is not cached.
 * - To invalidate (i.e. delete) the cached data, use {@link #invalidate}.
 * - If you only need the data right now and don't want to deal with subscriptions, use {@link #dataAsPromise}
 *   to access it as a promise that will either return cached data or load new data.
 *
 * When done using a DataQuery subscription, it *must* be manually destroyed by calling
 * [`sub.unsubscribe()`]{@link Subscription#unsubscribe} on the Subscription object returned by
 * [subscribe]{@link Observable#subscribe}, as it may leak memory otherwise.
 *
 * See {@link DataNode} and {@link DataList} for the two main types of data that use DataQuery.
 *
 * @typeParam I - ID type (e.g. NodeId or ListId)
 * @typeParam T - type of data accessible via .current
 * @typeParam R - type returned by innerQueryFn
 * @typeParam P - parameter type for innerQueryFn
 */
export abstract class DataQuery<I, T, R, P> extends Observable<T> {
  /** The ID of this data. */
  id: I;
  loading = false; // TODO: maybe make this value observable too?
  /**
   * @ignore
   * Private: this is the currently loaded data, externally accessible via .current.
   */
  protected currentData?: T;
  /**
   * @ignore
   * Private: this is the time the data was last loaded, to compare against the cache invalidation
   * timeout. This is a millisecond epoch timestamp from Date.now().
   */
  protected lastLoadTime = 0;
  /**
   * @ignore
   * Private: this flag may be set by subclasses to avoid having writes to .params load any data.
   */
  protected pSetParamsNoUpdate = false;

  /** Returns true if data is currently available. */
  get hasData(): boolean {
    return this.currentData !== undefined;
  }

  /** The currently loaded data. */
  get current(): T {
    return this.currentData;
  }

  /**
   * @ignore
   * The current query parameters, externally accessible via .params.
   */
  protected currentQueryParams?: P;
  /**
   * Parameters that will be passed to the request.
   * Changing this property will automatically trigger a load.
   */
  get params(): P | undefined {
    return this.currentQueryParams;
  }
  set params(p: P) {
    this.currentQueryParams = p;
    if (!this.pSetParamsNoUpdate) {
      this.loadDebounced();
    }
  }

  /**
   * @ignore
   * Private: set of all subscribers to this data. This set is used to send updates.
   */
  protected subscribers: Set<Subscriber<T>> = new Set();
  // FIXME: innerQuery/MapFn is a bit inelegant; it may be possible to refactor this
  /**
   * @ignore
   * Private: this is the inner query function that actually loads the data, provided by a subclass.
   */
  protected innerQueryFn: (id: I, p: P) => Promise<R>;
  /**
   * @ignore
   * Private: this function maps data returned by the inner query into our format, if necessary.
   */
  protected innerMapFn: (r: R) => T;
  /**
   * @ignore
   * Private: this is a simple counter used to determine whether the result of a load operation is
   * still relevant.
   */
  protected stateLock = 0;
  /**
   * @ignore
   * Private: this is a javascript timeout ID set when doing debounced loading.
   */
  protected loadTimeout = null;
  /**
   * @ignore
   * Private: if true, the data will be hydrated (see {@link DataList#hydrateInitial}) and we should
   * *not* trigger a load when a subscriber is added, until we have received the hydration.
   */
  protected hydrated = false;
  /**
   * @ignore
   * Private: if true, the next call to subscribe will add a lazy subscriber.
   * The flag will be reset automatically. Used in subscribeLazy.
   */
  protected isNextSubLazy = false;

  /** If true, will prolong debounce time a bit. */
  interactive = false;

  /**
   * @ignore
   * Creates a new DataQuery (you should never need to use this directly)
   *
   * @param id an identifier for the data being loaded
   * @param query the inner query function
   * @param map maps returned data from the query R to usable data T
   */
  protected constructor(id: I, query: (id: I, p: P) => Promise<R>, map: (r: R) => T) {
    super((subscriber) => {
      this.addSubscriber(subscriber, this.isNextSubLazy);
      this.isNextSubLazy = false;
    });
    this.id = id;
    this.innerQueryFn = query;
    this.innerMapFn = map;
  }

  /**
   * Returns the data as a promise, without having to create a subscription.
   *
   * If cached data is available, this will return the data immediately; otherwise, this will
   * load the data with an API request.
   *
   * #### Example
   * ```ts
   * const node = dataService.getNode(someNodeId);
   * node.dataAsPromise().then(data => {
   *   console.log('node data:', data);
   * }).catch(error => console.error('oh no'));
   * ```
   */
  dataAsPromise(): Promise<T> {
    if (this.hasData) {
      return Promise.resolve(this.current);
    }
    return new Promise((resolve, reject) => {
      const sub = this.subscribe(
        (data) => {
          resolve(data);
          sub.unsubscribe();
        },
        (error) => {
          reject(error);
          sub.unsubscribe();
        }
      );
    });
  }

  /**
   * @ignore
   * Private: the actual implementation of the load function.
   */
  private loadImpl(fut: Promise<R>) {
    clearTimeout(this.loadTimeout);
    this.loadTimeout = null;
    this.lastLoadTime = Date.now();
    this.loading = true;

    // if load is called twice; only the newest load call will have an effect
    const stateLock = ++this.stateLock;

    fut
      .then((data) => {
        if (stateLock !== this.stateLock) {
          return;
        }
        this.insertResult(data);
        this.loading = false;
        this.hydrated = false;
      })
      .catch((error) => {
        if (stateLock !== this.stateLock) {
          return;
        }
        this.emitErrorToAllSubscribers(error);
        this.loading = false;
        this.hydrated = false;
      });
  }

  /** Loads data. */
  load(): void {
    this.hydrated = false;
    this.loadImpl(this.innerQueryFn(this.id, this.currentQueryParams));
  }

  /**
   * @internal
   * Use when data has not yet been loaded but is available from elsewhere.
   */
  hydrateRaw(preparedData: Promise<R>): void {
    if (this.hasData) {
      return; // don't need hydration
    }
    this.hydrated = true;
    this.loadImpl(preparedData);
  }

  /** Will load data if it's stale or not present. */
  loadIfNeeded(): void {
    if (this.loading) {
      return;
    }
    if (!this.hasData || Date.now() - this.lastLoadTime > CACHE_STALE_TIME_MS) {
      this.load();
    }
  }

  /** Loads data after a short delay. Will debounce. */
  loadDebounced(interactive = this.interactive): void {
    if (this.loadTimeout) {
      return;
    }
    this.loadTimeout = setTimeout(
      () => {
        this.loadTimeout = null;
        this.load();
      },
      interactive ? CACHE_INTERACTIVE_DEBOUNCE_TIME_MS : CACHE_FAST_DEBOUNCE_TIME_MS
    );
  }

  /** Deletes current data. */
  invalidate(): void {
    this.currentData = undefined;
    this.emitUpdateToAllSubscribers();
  }

  /**
   * @ignore
   * Private: callback for adding a new subscriber.
   */
  protected addSubscriber(subscriber: Subscriber<T>, lazy: boolean): {unsubscribe: () => void} {
    this.subscribers.add(subscriber);
    if (this.current !== undefined) {
      // data is available right now! emit current state
      subscriber.next(this.current);
    }

    if (!this.hydrated && (!lazy || !this.hasData)) {
      this.loadIfNeeded();
    }

    return {
      unsubscribe: () => {
        this.subscribers.delete(subscriber);
      }
    };
  }

  /**
   * Will subscribe to the data, but not cause a reload unless there is no data.
   * @param args passed verbatim to [#subscribe]{@link Observable#subscribe}
   */
  subscribeLazy(...args): Subscription {
    this.isNextSubLazy = true;
    return this.subscribe(...args);
  }

  /**
   * @ignore
   * Internal: will send an update with the current data (.current) to all subscribers.
   */
  emitUpdateToAllSubscribers(): void {
    for (const sub of this.subscribers) {
      sub.next(this.current);
    }
  }

  /**
   * @ignore
   * Internal: will send the given error to all subscribers.
   */
  emitErrorToAllSubscribers(error: unknown): void {
    for (const sub of this.subscribers) {
      sub.error(error);
    }
  }

  /**
   * @ignore
   * Updates current data with a result from innerQueryFn, and emits an update.
   */
  insertResult(result: R): void {
    this.currentData = this.innerMapFn(result);
    this.emitUpdateToAllSubscribers();
  }

  /** Returns the number of subscribers for this data. */
  get subscriberCount(): number {
    return this.subscribers.size;
  }
}

/**
 * A cacheable node with no parameters.
 *
 * See {@link DataQuery} for more information, and {@link DataService} to obtain a DataNode.
 * Nodes are identified by a {@link NodeId}.
 *
 * #### Example
 * ```html
 * <div class="example-component">
 *   Is it loading? {{thing$.loading ? 'yes' : 'no'}}
 *   Is the thing loaded? {{thing$.hasData ? 'yes' : 'no'}}
 *   <div *ngIf="thing$.current as thing">
 *     Thing data: {{thing.something}}
 *   </div>
 * </div>
 * ```
 * ```ts
 * class ExampleComponent implements OnInit, OnDestroy {
 *   @Input() thingId: NodeId;
 *
 *   public thing$: DataNode<Thing>;
 *   public thingSub: Subscription; // subscription to thing$
 *
 *   constructor(private dataService: DataService) {}}
 *
 *   ngOnInit() {
 *     // obtain the DataNode from the data service
 *     this.thing$ = this.dataService.getNode(this.thingId);
 *
 *     // subscribe to indicate that we want some data
 *     this.thingSub = this.thing$.subscribe();
 *   }
 *
 *   ngOnDestroy() {
 *     // remember to unsubscribe!!
 *     this.thingSub.unsubscribe();
 *   }
 * }
 * ```
 */
export class DataNode<T> extends DataQuery<NodeId, T, T, void> {
  /** @ignore */
  constructor(queries: QueriesService, id: NodeId) {
    super(id, queryNode(queries), (data) => data);
  }

  set params(p) {
    throw new Error('parameters not available on nodes');
  }

  loadIfNeeded(): void {
    if (!this.loading && Date.now() - this.lastLoadTime > CACHE_STALE_TIME_MS) {
      this.load();
    }
  }
}

/**
 * Provides a view into a list of items.
 *
 * See {@link DataQuery} for more information, and {@link DataService} to obtain a DataList.
 * Lists are identified by a {@link ListId}.
 *
 * - To access list items, use {@link #currentItems}.
 * - If you need the IDs as well, use {@link #current} (note that the Map is ordered).
 *
 * The current view is defined by following properties:
 *
 * - {@link #cursor}: the current NodeId cursor (see backend API documentation for details)
 * - {@link #count}: number of items to load
 * - {@link #forward}: if true, will load items after the cursor. If false, will load items before.
 * - {@link #filter}: filter object (type parameter F)
 *
 * Changing any of these properties will reload the list (debounced).
 *
 * @typeParam T - list item type
 * @typeParam F - list filter type
 *
 * #### Example
 * ```html
 * <div class="example-component">
 *   <div *ngFor="let thing of things$.currentItems">
 *     a thing! {{thing.something}}
 *   </div>
 * </div>
 * ```
 * ```ts
 * class ExampleComponent implements OnInit, OnDestroy {
 *   @Input() thingsListId: ListId;
 *
 *   things$: DataList<Thing, unknown>; // filter type unknown because we're not using here
 *   thingsSub: Subscription;
 *
 *   constructor(private dataService: DataService) {}}
 *
 *   ngOnInit() {
 *     // obtain a list view from the data service
 *     this.things$ = this.dataService.getList(this.thingsListId);
 *
 *     // subscribe to the list to indicate that we want some data
 *     this.thingsSub = this.things$.subscribe();
 *   }
 *
 *   ngOnDestroy() {
 *     // remember to unsubscribe!!
 *     this.thingsSub.unsubscribe();
 *   }
 * }
 * ```
 */
export class DataList<T, F> extends DataQuery<ListId, Map<NodeIdEnc, T>, ListResult<T>, ListParams<F>> {
  // these are all just the private versions of the corresponding list properties.
  /** @ignore */
  private pCursor?: NodeId;
  /** @ignore */
  private pCount = 10;
  /** @ignore */
  private pFilter?: F;
  /** @ignore */
  private pForward = true;

  /**
   * @ignore
   * Private: page info for the currently loaded data.
   */
  private pageInfo?: PageInfo;
  /**
   * @ignore
   * Private: accessible via .totalCount (read-only)
   */
  private pTotalCount?: number;
  /**
   * @ignore
   * Private: used to correct hasPrevious/NextPage when receiving data.
   */
  private previouslyHadPageContents = false;
  /**
   * @ignore
   * Private: pointer to the global node cache, used to insert results.
   */
  private pNodes: NodeCache;

  /** @ignore */
  constructor(queries: QueriesService, nodes: NodeCache, id: ListId) {
    super(id, queryList(queries, nodes), (result) => {
      this.pageInfo = result.pageInfo;
      this.pTotalCount = result.totalCount;

      // API *only* reports hasPreviousPage or hasNextPage correctly if we are navigating in that
      // same direction. Hence, we need to amend pageInfo with prior knowledge.
      if (this.forward) {
        this.pageInfo.hasPreviousPage = this.previouslyHadPageContents;
      } else {
        this.pageInfo.hasNextPage = this.previouslyHadPageContents;
      }
      this.previouslyHadPageContents = !!result.items.size;

      return result.items;
    });
    this.pNodes = nodes;
    this.pSetParamsNoUpdate = true;
    this.setParams();
    this.pSetParamsNoUpdate = false;
  }

  /**
   * @internal
   * Updates the `params` value from list parameters
   */
  setParams(): void {
    this.params = {
      cursor: this.pCursor,
      count: this.pCount,
      forward: this.pForward,
      filter: this.pFilter
    };
  }

  /** Returns the total number of items. Null if not loaded. */
  get totalCount(): number {
    return this.pTotalCount;
  }

  /** Returns the currently loaded items in an array. */
  get currentItems(): T[] {
    if (!this.hasData) {
      return [];
    }
    return [...this.current.values()];
  }

  /** Current list filter object. */
  get filter(): F | undefined {
    return this.pFilter;
  }
  set filter(f: F) {
    this.pFilter = f;
    this.setParams();
  }

  /** The current pagination cursor (a node relative to which items will be loaded). Nullable. */
  get cursor(): NodeId {
    return this.pCursor;
  }
  set cursor(c: NodeId) {
    this.pCursor = c;
    this.setParams();
  }

  /** The max amount of items to be loaded. */
  get count(): number {
    return this.pCount;
  }
  set count(c: number) {
    this.pCount = c;
    this.setParams();
  }

  /** Whether to load items after the cursor (true), or items before the cursor (false). */
  get forward(): boolean {
    return this.pForward;
  }
  set forward(f: boolean) {
    this.pForward = f;
    this.setParams();
  }

  /** Returns the node ID of the first item on the current page. */
  get firstPageItemId(): NodeId | null {
    const firstKey = this.current ? this.current.keys().next()?.value || null : null;
    return firstKey ? decodeNodeId(firstKey) : null;
  }

  /** Returns the node ID of the last item on the current page. */
  get lastPageItemId(): NodeId | null {
    if (!this.current) {
      return;
    }
    const keys = [...this.current.keys()];
    return keys[keys.length - 1] ? decodeNodeId(keys[keys.length - 1]) : null;
  }

  /** Returns true if the current result contains the given node. */
  currentHasNode(key: NodeId): boolean {
    return this.current?.has(encodeNodeId(key));
  }

  get hasPrevPage(): boolean {
    return !this.pageInfo || this.pageInfo.hasPreviousPage;
  }
  get hasNextPage(): boolean {
    return !this.pageInfo || this.pageInfo.hasNextPage;
  }

  /** Moves the view to the first page. */
  firstPage(): boolean {
    this.cursor = null;
    this.forward = true;
    this.previouslyHadPageContents = false;
    this.invalidate();
    return true;
  }

  /** Moves the view to the previous page. */
  prevPage(): boolean {
    if (this.pageInfo && !this.pageInfo.hasPreviousPage) {
      return false;
    }
    this.cursor = this.firstPageItemId;
    this.forward = false;
    this.invalidate();
    return true;
  }

  /** Moves the view to the next page. */
  nextPage(): boolean {
    if (this.pageInfo && !this.pageInfo.hasNextPage) {
      return false;
    }
    this.cursor = this.lastPageItemId;
    this.forward = true;
    this.invalidate();
    return true;
  }

  /**
   * Hydrates this list with initial data in the API format
   *
   * If you've already got data from the API that contains the first page of this list, you can use
   * this method to insert that data directly and avoid triggering a redundant API request.
   *
   * @param data a promise that returns the API data
   * @typeParam IdT - equivalent to T
   */
  hydrateInitial<IdT extends T & {id: string; __typename: string}>(data: Promise<HydrateList<IdT>>): void {
    this.hydrateRaw(
      data.then((value) => ({
        totalCount: value.totalCount,
        pageInfo: value.pageInfo,
        items: this.pNodes.insertNodes(value.nodes || [])
      }))
    );
  }
}

/** List hydration object (constructing this manually shouldn't be necessary as it mirrors the structure of GQL objects) */
export type HydrateList<T> = {
  totalCount: number;
  pageInfo: PageInfo;
  /** This is nullable because it's nullable in the GQL schema. In practice it should always exist */
  nodes?: (T | null)[];
};

/** Keeps a cache of DataNodes such that each NodeId has at most one associated DataNode. */
export class NodeCache {
  // TODO: garbage collection? (nodes with zero subscribers)
  /**
   * @internal
   * Internal node storage. Do not use directly.
   */
  nodes: Map<NodeIdEnc, DataNode<unknown>> = new Map();

  constructor(private queries: QueriesService) {}

  /** Creates a new node. */
  private createNode(id: NodeId) {
    const encodedId = encodeNodeId(id);
    this.nodes.set(encodedId, new DataNode(this.queries, id));
  }

  /** Returns the DataNode for the given NodeId. */
  getNode<T>(id: NodeId): DataNode<T> {
    const encodedId = encodeNodeId(id);
    if (!this.nodes.has(encodedId)) {
      this.createNode(id);
    }
    return this.nodes.get(encodedId) as DataNode<T>;
  }

  /**
   * Inserts nodes into the cache and returns them as a map (in the same order).
   *
   * Note: the ID parameter of the node is only optional for type compatibility with the GQL schema.
   * Nodes without an ID will be ignored.
   */
  insertNodes<T extends {id?: string; __typename?: string}>(nodes: T[]): Map<NodeIdEnc, T> {
    const map = new Map();

    for (const node of nodes) {
      if (!node?.id) {
        continue;
      }
      const type = nodeTypeFromTypename(node.__typename);
      const id = {type, id: node.id};
      const dataNode: DataNode<T> = this.getNode(id);
      if (!dataNode.hasData) {
        // FIXME: different queries load different amounts of data, simple overwriting doesn't always have the desired effect
        //  S1: distinguish between nodes and "partial nodes"?
        //  S2: deep merge data?
        dataNode.insertResult(node);
      }
      map.set(encodeNodeId(id), node);
    }

    return map;
  }
}

results matching ""

    No results matching ""