Skip to main content

GQLEntity

GraphQL has one standard way of defining the pk, which is with an id field.

GQLEntity come with an id field automatically, which is used for the pk.

extends

GQLEntity extends Entity

Usage

import { GQLEntity } from '@data-client/graphql';

export class User extends GQLEntity {
  username = '';
}
import { GQLEntity } from '@data-client/graphql';
import { User } from './User';

export class Article extends GQLEntity {
  title = '';
  content = '';
  author = User.fromJS();
  tags: string[] = [];
  createdAt = Temporal.Instant.fromEpochSeconds(0);

  static schema = {
    author: User,
    createdAt: Temporal.Instant.from,
  };
}

static schema is a declarative definition of fields to process. In this case, author is another Entity to be extracted, and createdAt will be converted from a string to a Date object.

tip

Entities are bound to GQLEndpoints using the second argument of query or mutate.

Other static members overrides allow customizing the data lifecycle as seen below.

Data lifecycle

Methods

pk(parent?, key?, args?): string?

PK stands for primary key and is intended to provide a standard means of retrieving a key identifier for any Entity.

GraphQL uses the id field as the standard global object identifier.

pk() {
return this.id;
}

static key: string

This defines the key for the Entity itself, rather than an instance. This needs to be a globally unique value.

warning

This defaults to this.name; however this may break in production builds that change class names. This is often know as class name mangling.

In these cases you can override key or disable class mangling.

class User extends GQLEntity {
username = '';

static key = 'User';
}

static process(input, parent, key, args): processedEntity

Run at the start of normalization for this entity. Return value is saved in store.

Defaults to simply copying the response ({...input})

How to override to build reverse-lookups for relational data

static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue

static mergeWithStore(
existingMeta: {
date: number;
fetchedAt: number;
},
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
const shouldUpdate = this.shouldUpdate(
existingMeta,
incomingMeta,
existing,
incoming,
);

if (shouldUpdate) {
// distinct types are not mergeable (like delete symbol), so just replace
if (typeof incoming !== typeof existing) {
return incoming;
} else {
return this.shouldReorder(
existingMeta,
incomingMeta,
existing,
incoming,
)
? this.merge(incoming, existing)
: this.merge(existing, incoming);
}
} else {
return existing;
}
}

mergeWithStore() is called during normalization when a processed entity is already found in the store.

This calls shouldUpdate(), shouldReorder() and potentially merge()

static shouldUpdate(existingMeta, incomingMeta, existing, incoming): boolean

static shouldUpdate(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return existingMeta.fetchedAt <= incomingMeta.fetchedAt;
}

Preventing updates

shouldUpdate can also be used to short-circuit an entity update.

import deepEqual from 'deep-equal';

class Article extends GQLEntity {
title = '';
content = '';
published = false;

static shouldUpdate(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return !deepEqual(incoming, existing);
}
}

static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean

static shouldReorder(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return incomingMeta.fetchedAt < existingMeta.fetchedAt;
}

true return value will reorder incoming vs in-store entity argument order in merge. With the default merge, this will cause the fields of existing entities to override those of incoming, rather than the other way around.

Example

import { GQLEntity } from '@data-client/graphql';

export class LatestPriceEntity extends GQLEntity {
  updatedAt = 0;
  price = '0.0';
  symbol = '';

  static shouldReorder(
    existingMeta: { date: number; fetchedAt: number },
    incomingMeta: { date: number; fetchedAt: number },
    existing: { updatedAt: number },
    incoming: { updatedAt: number },
  ) {
    return incoming.updatedAt < existing.updatedAt;
  }
}

static merge(existing, incoming): mergedValue

static merge(existing: any, incoming: any) {
return {
...existing,
...incoming,
};
}

Merge is used to handle cases when an incoming entity is already found. This is called directly when the same entity is found in one response. By default it is also called when mergeWithStore() determines the incoming entity should be merged with an entity already persisted in the Reactive Data Client store.

How to override to build reverse-lookups for relational data

static mergeMetaWithStore(existingMeta, incomingMeta, existing, incoming): meta

static mergeMetaWithStore(
existingMeta: {
expiresAt: number;
date: number;
fetchedAt: number;
},
incomingMeta: { expiresAt: number; date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return this.shouldReorder(existingMeta, incomingMeta, existing, incoming)
? existingMeta
: incomingMeta;
}

mergeMetaWithStore() is called during normalization when a processed entity is already found in the store.

static queryKey(args, queryKey, getEntity, getIndex): pk?

This method enables Entities to be Queryable - allowing store access without an endpoint.

Overriding can allow customization or disabling of this behavior altogether.

Returning undefined will disallow this behavior.

Returning pk string will attempt to lookup this entity and use in the response.

When used, expiry policy is computed based on the entity's own meta data.

By default uses the first argument to lookup in pk() and indexes

static createIfValid(processedEntity): Entity | undefined

Called when denormalizing an entity. This will create an instance of this class if it is deemed 'valid'.

undefined return will result in Invalid expiry status, like Invalidate.

Invalid expiry generally means hooks will enter a loading state and attempt a new fetch.

static createIfValid(props): AbstractInstanceType<this> | undefined {
if (this.validate(props)) {
return undefined as any;
}
return this.fromJS(props);
}

static validate(processedEntity): errorMessage?

Runs during both normalize and denormalize. Returning a string indicates an error (the string is the message).

During normalization a validation failure will result in an error for that fetch.

During denormalization a validation failure will mark that result as 'invalid' and thus will block on fetching a result.

By default does some basic field existance checks in development mode only. Override to disable or customize.

Using validation for endpoints with incomplete fields

static fromJS(props): Entity

Factory method that copies props to a new instance. Use this instead of new MyEntity(), to ensure default props are overridden.

Fields

static schema: { [k: keyof this]: Schema }

Defines related entity members, or field deserialization like Date and BigNumber.

Fixtures
query getPost($id: ID!) { post(id: $id) { id author createdAt content title } } {"id":"123"}
{"post":{"id":"5","author":{"id":"123","name":"Jim"},"content":"Happy day","createdAt":"2019-01-23T06:07:48.311Z"}}
User
import { GQLEntity } from '@data-client/graphql';

export class User extends GQLEntity {
  name = '';
}
Post
import { GQLEntity } from '@data-client/graphql';
import { User } from './User';

export class Post extends GQLEntity {
  author = User.fromJS({});
  createdAt = Temporal.Instant.fromEpochSeconds(0);
  content = '';
  title = '';

  static schema = {
    author: User,
    createdAt: Temporal.Instant.from,
  };
  static key = 'Post';
}
PostPage
import { GQLEndpoint } from '@data-client/graphql';
import { Post } from './Post';

const gql = new GQLEndpoint('https://fakeapi.com');
export const getPost = gql.query(
  (v: { id: string }) => `query getPost($id: ID!) {
    post(id: $id) {
      id
      author
      createdAt
      content
      title
    }
  }`,
  { post: Post },
);

function PostPage() {
  const { post } = useSuspense(getPost, { id: '123' });
  return (
    <div>
      <p>
        {post.content} - <cite>{post.author.name}</cite>
      </p>
      <time>
        {DateTimeFormat('en-US', { dateStyle: 'medium' }).format(
          post.createdAt,
        )}
      </time>
    </div>
  );
}
render(<PostPage />);
🔴 Live Preview
Store

Optional members

Entities references here whose default values in the Record definition itself are considered 'optional'

class User extends GQLEntity {
friend: User | null = null; // this field is optional
lastUpdated = Temporal.Instant.fromEpochSeconds(0);

static schema = {
friend: User,
lastUpdated: Temporal.Instant.from,
};
}

static indexes?: (keyof this)[]

Indexes enable increased performance when doing lookups based on those parameters. Add fieldnames (like slug, username) to the list that you want to send as params to lookup later.

note

Don't add your primary key like id to the indexes list, as that will already be optimized.