Entity
{
Article: {
'1': {
id: '1',
title: 'Entities define data',
}
}
}
Entity
defines a single unique object.
Entity.key + Entity.pk() (primary key) enable a flat lookup table store, enabling high performance, data consistency and atomic mutations.
Entities
enable customizing the data processing lifecycle by defining its static members like schema
and overriding its lifecycle methods.
Usage
import { Entity } from '@data-client/rest'; import { User } from './User'; export class Article extends Entity { id = ''; title = ''; content = ''; author = User.fromJS(); tags: string[] = []; createdAt = Temporal.Instant.fromEpochSeconds(0); static key = 'Article'; pk() { return this.id; } 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.
Entities are bound to Endpoints using resource.schema or RestEndpoint.schema
If you already have your classes defined, EntityMixin can also be used to make Entities.
Other static members overrides allow customizing the data lifecycle as seen below.
Members
pk(parent?, key?, args?): string | number | undefined
pk stands for primary key, uniquely identifying an Entity
instance.
By default this returns the an Entity's id
field.
Override this method to use other fields, or to for other cases like multicolumn primary keys.
undefined value
A undefined
can be used as a default to indicate the entity has not been created yet.
This is useful when initializing a creation form using Entity.fromJS()
directly. If pk()
returns undefined
it is considered not persisted to the server,
and thus will not be kept in the cache.
Other uses
Since pk()
is unique, it provides a consistent way of defining JSX list keys
//....
return (
<div>
{results.map(result => (
<TheThing key={result.pk()} thing={result} />
))}
</div>
);
Singleton Entities
What if there is only ever once instance of a Entity for your entire application? You
don't really need to distinguish between each instance, so likely there was no id
or
similar field defined in the API. In these cases you can just return a literal like
'the_only_one'.
pk() {
return 'the_only_one';
}
In case you have
const get = new RestEndpoint({
path: '/options',
schema: OptionsEntity,
});
export const OptionsResource = {
get,
partialUpdate: get.extend({ method: 'PATCH' }),
}
static key: string
This defines the key for the Entity kind, rather than an instance. This needs to be a globally unique value.
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 name mangling.
class User extends Entity {
id = '';
username = '';
pk() {
return this.id;
}
static key = 'User';
}
static schema: { [k: keyof this]: Schema }
Defines related entity members, or field deserialization like Date and BigNumber.
{"id":"5","author":{"id":"123","name":"Jim"},"content":"Happy day","createdAt":"2019-01-23T06:07:48.311Z"}
import { Entity } from '@data-client/rest'; import { User } from './User'; export class Post extends Entity { id = ''; author = User.fromJS(); createdAt = Temporal.Instant.fromEpochSeconds(0); content = ''; title = ''; pk() { return this.id; } static key = 'Post'; static schema = { author: User, createdAt: Temporal.Instant.from, }; }
Optional members
Entities references here whose default values in the Record definition itself are considered 'optional'
class User extends Entity {
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.
Don't add your primary key like id
to the indexes list, as that will already be optimized.
useSuspense()
With useSuspense() this will eagerly infer the results from entities table if possible, rendering without needing to complete the fetch. This is typically helpful when the entities cache has already been populated by another request like a list request.
export class User extends Entity {
id: number | undefined = undefined;
username = '';
email = '';
isAdmin = false;
static indexes = ['username' as const];
}
export const UserResource = resource({
path: '/user/:id',
schema: User,
});
const user = useSuspense(UserResource.get, { username: 'bob' });
useQuery()
With useQuery(), this enables accessing results retrieved inside other requests - even if there is no endpoint it can be fetched from.
class LatestPrice extends Entity {
id = '';
symbol = '';
price = '0.0';
static indexes = ['symbol' as const];
}
class Asset extends Entity {
id = '';
price = '';
static schema = {
price: LatestPrice,
};
}
const getAssets = new RestEndpoint({
path: '/assets',
schema: [Asset],
});
Some top level component:
const assets = useSuspense(getAssets);
Nested below:
const price = useQuery(LatestPrice, { symbol: 'BTC' });
Lifecycle
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.
static process(input, parent, key, args): processedEntity
Run at the start of normalization for this entity. Return value is saved in store and sent to pk().
Defaults to simply copying the response ({...input}
)
How to override to build reverse-lookups for relational data
Case of the missing id
class Stream extends Entity {
username = '';
title = '';
game = '';
currentViewers = 0;
live = false;
pk() {
return this.username;
}
static key = 'Stream';
process(value, parent, key, args) {
// super.process creates a copy of value
const processed = super.process(value, parent, key, args);
processed.username = args[0]?.username;
return processed;
}
}
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 Entity {
id = '';
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
class LatestPriceEntity extends Entity { id = ''; updatedAt = 0; price = '0.0'; symbol = ''; pk() { return this.id; } 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
getEntity(key, pk?)
Gets all entities of a type with one argument, or a single entity with two
const entitiesEntry = getEntity(this.schema.key);
if (entitiesEntry === undefined) return INVALID;
return Object.values(entitiesEntry).map(
entity => entity && this.schema.pk(entity),
);
if (getEntity(this.key, id)) return id;
getIndex(key, indexName, value)
Returns the index entry (value->pk map)
const value = args[0][indexName];
return getIndex(schema.key, indexName, value)[value];
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.