Skip to content

Commit

Permalink
feat|doc|fix: cleanup includes story (#9583)
Browse files Browse the repository at this point in the history
* feat: potential avenue for enabling typed string includes

* cleanup docs references

* add docs and cleanup adapter default

* normalize only for JSON:API

* fix verbiage

* fix forgotten delted file
  • Loading branch information
runspired authored Oct 21, 2024
1 parent 4c552f0 commit 55714da
Show file tree
Hide file tree
Showing 11 changed files with 293 additions and 15 deletions.
6 changes: 3 additions & 3 deletions guides/requests/examples/0-basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ import fetch from './fetch';

// ... execute a request
const { content: collection } = await fetch.request(query('company', {
include: 'ceo',
include: ['ceo'],
fields: {
company: 'name',
employee: ['name', 'profileImage']
Expand Down Expand Up @@ -182,7 +182,7 @@ import { query } from '@ember-data/json-api/request';

// ... execute a request
const { content: collection } = await store.request(query('company', {
include: 'ceo',
include: ['ceo'],
fields: {
company: 'name',
employee: ['name', 'profileImage']
Expand Down Expand Up @@ -249,7 +249,7 @@ import fetch from './fetch';
// ... execute a request
try {
const result = await fetch.request(query('company', {
include: 'ceo',
include: ['ceo'],
fields: {
company: 'name',
employee: ['name', 'profileImage']
Expand Down
125 changes: 125 additions & 0 deletions guides/typescript/5-typing-includes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Typing Includes

Many APIs offer the concept of "sideloading" or "including related resources". For instance,
when loading a `user` you might also want to load the information for the company where they
work, the company's CEO, and a list of the user's friends. Historically this was managed
through the `includes` param.

For instance, in the example above, the includes array would usually look like this:

```ts
{ include: ['company', 'company.ceo', 'friends'] }
```

Though some users author this requirement as a string instead:

```ts
{ include: 'company,company.ceo,friends' }
```

> [!TIP]
> We recommend authoring includes as an array instead of as a string. It will
> generally scale better if the list is long, and provides better autocomplete support.
> Within WarpDrive/EmberData provide builders and adapters there should be no functional
> difference between using an array or using a string.
Typing relationship paths like includes is valuable for increased confidence, as small typos in these
strings could result in significant application bugs. And, where possible, autocomplete support while
typing these strings can help a developer better learn and explore the graph of data available to be loaded.

WarpDrive offers several type utilities to assist with strictly typing strings that represent relationship
paths: `Includes` and `StringSatisfiesIncludes`


## The `Includes` Type Util

The `Includes` type util will return a union of all valid relationship paths discoverable from the input type, within a few constraints.

- Cyclical paths are eliminated, so if a user has friends that are users you should never see `user.friends.friends` or `user.friends.company` as options.
- There is a configurable MAX_DEPTH which defaults to `3` to help encourage reduced complexity and better typescript performance
- There is an absolute MAX-DEPTH for paths of `5`. If you wish to support longer paths than `5` please reach out to discuss. This limit is in place for performance reasons due to the size of union that gets generated.
- If your type/model has non-relationship properties that compute to typed record instances (or arrays of typed record instances) you may encounter false positives for paths.

> [!TIP]
> In general, we discourage the usage of `getters` (aka `computed` or `derived` fields) that compute their value from related records.

```ts
Includes<
T extends TypedRecordInstance,
MAX_DEPTH extends _DEPTHCOUNT = DEFAULT_MAX_DEPTH
>
```

### Basic Usage

```ts
import type { Includes } from '@warp-drive/core-types/record';

function builderThatAcceptsIncludes<T extends TypedRecordInstance>(req: {
includes: Includes<T>[]
// ... other props
});

builderThatAcceptsIncludes<User>({
includes: ['friends']
})
```

## The `StringSatisfiesIncludes` Type Util

Due to limitations in TypeScript and the underlying (poor) algorithmic
performance that would result from many approaches, comma-separated-string based
include arguments (e.g. `'company,company.ceo,friends'`) aren't typed by-default.

However, if you wish to support validating these strings with types, we offer a
stand-alone utility with reasonably good performance characteristics and minimal
runtime overhead.

We mention runtime overhead as it requires creating a function to have it work
with reasonable DX.

This approach has two main drawbacks: it currently does not autocomplete (though
we believe there's a path to making it do so) and its up to the developer to use
the validator at the callsite, its not automatic.

### Using the Runtime Function

```ts
import { createIncludeValidator } from '@warp-drive/core-types/record';

const userIncludesValidator = createIncludeValidator<User>;

function builderThatAcceptsIncludes<T extends TypedRecordInstance>(req: {
includes: string
// ... other props
});

builderThatAcceptsIncludes<User>({
includes: userIncludesValidator('company,company.ceo,friends')
})
```

### Using the Type Util Directly

The type util that powers `createIncludeValidator` can be used directly; however, we only
recommend doing so if writing a wrapper utility similar to `createIncludeValidator` as
otherwise it results in needing to type out the string twice.

```ts
import type { StringSatisfiesIncludes, Includes } from '@warp-drive/core-types/record';

function builderThatAcceptsIncludes<T extends TypedRecordInstance>(req: {
includes: string
// ... other props
});

const includes: StringSatisfiedIncludes<
'company,company.ceo,friends',
Includes<User>
> = 'company,company.ceo,friends';

builderThatAcceptsIncludes<User>({
includes
})
```
1 change: 1 addition & 0 deletions guides/typescript/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ the following two sections
- [Why Brands](./2-why-brands.md)
- [Typing Models & Transforms](./3-typing-models.md)
- [Typing Requests & Builders](./4-typing-requests.md)
- [Typing Includes](./5-typing-includes.md)
- Typing Handlers
- Using Store APIs

Expand Down
29 changes: 27 additions & 2 deletions packages/adapter/src/json-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
@module @ember-data/adapter/json-api
*/
import type { AdapterPayload } from '@ember-data/legacy-compat';
import type { Snapshot } from '@ember-data/legacy-compat/-private';
import type { Snapshot, SnapshotRecordArray } from '@ember-data/legacy-compat/-private';
import { dasherize, pluralize } from '@ember-data/request-utils/string';
import type Store from '@ember-data/store';
import type { ModelSchema } from '@ember-data/store/types';
import { assert } from '@warp-drive/build-config/macros';
import type { HTTPMethod } from '@warp-drive/core-types/request';

import { serializeIntoHash } from './-private';
import type { FetchRequestInit, JQueryRequestInit } from './rest';
import type { FetchRequestInit, JQueryRequestInit, QueryState } from './rest';
import RESTAdapter from './rest';

/**
Expand Down Expand Up @@ -269,6 +269,31 @@ class JSONAPIAdapter extends RESTAdapter {

return this.ajax(url, 'PATCH', { data: data });
}

/**
Used by `findAll` and `findRecord` to build the query's `data` hash
supplied to the ajax method.
@method buildQuery
@since 2.5.0
@public
@param {Snapshot} snapshot
@return {Object}
*/
buildQuery(snapshot: Snapshot | SnapshotRecordArray): QueryState {
const query: QueryState = {};

if (snapshot) {
const { include } = snapshot;
const normalizedInclude = Array.isArray(include) ? include.join(',') : include;

if (normalizedInclude) {
query.include = normalizedInclude;
}
}

return query;
}
}

export default JSONAPIAdapter;
5 changes: 4 additions & 1 deletion packages/adapter/src/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import Adapter, { BuildURLMixin } from './index';

type Payload = Error | Record<string, unknown> | unknown[] | string | undefined;

type QueryState = {
export type QueryState = {
include?: unknown;
since?: unknown;
};
Expand Down Expand Up @@ -1273,6 +1273,9 @@ class RESTAdapter extends Adapter.extend(BuildURLMixin) {
const { include } = snapshot;

if (include) {
// note: if user passed in an array, this will serialize like `?include[]=foo&include[]=bar`
// but if user passed in a string, this will serialize like `?include=foo,bar`
// users that want consistent behavior should override this method
query.include = include;
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/core-types/src/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type Serializable = SerializablePrimitive | SerializablePrimitive[];
export type QueryParamsSerializationOptions = {
arrayFormat?: 'bracket' | 'indices' | 'repeat' | 'comma';
};

export type QueryParamsSource<T = unknown> =
| ({ include?: T extends TypedRecordInstance ? Includes<T>[] : string | string[] } & Record<
Exclude<string, 'include'>,
Expand Down
16 changes: 16 additions & 0 deletions packages/core-types/src/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,19 @@ export type Includes<T extends TypedRecordInstance, MAX_DEPTH extends _DEPTHCOUN
>;

export type OpaqueRecordInstance = unknown;

export type _StringSatisfiesIncludes<T extends string, SET extends string, FT extends string> = T extends SET
? FT
: T extends `${infer U},${infer V}`
? U extends SET
? _StringSatisfiesIncludes<V, Exclude<SET, U>, FT>
: never
: never;

export type StringSatisfiesIncludes<T extends string, SET extends string> = _StringSatisfiesIncludes<T, SET, T>;

export function createIncludeValidator<T extends TypedRecordInstance>() {
return function validateIncludes<U extends string>(includes: StringSatisfiesIncludes<U, Includes<T>>): U {
return includes;
};
}
110 changes: 108 additions & 2 deletions packages/core-types/src/record.type-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// test
/* eslint-disable @typescript-eslint/no-unused-vars */
// tests

import type { ExtractSuggestedCacheTypes, Includes, TypedRecordInstance } from './record';
import type { ExtractSuggestedCacheTypes, Includes, StringSatisfiesIncludes, TypedRecordInstance } from './record';
import { createIncludeValidator } from './record';
import type { Type } from './symbols';

type NoRelations = {
Expand Down Expand Up @@ -139,3 +141,107 @@ takesIncludes<NoRelations>([
// @ts-expect-error not a valid path since it doesn't exist
'not',
]);

const validator = createIncludeValidator<MyThing>();

function expectString(t: string) {}
function expectNever(t: never) {}

expectString(validator('relatedThing,otherThing,otherThings.thirdThing'));
expectString(validator('relatedThing,otherThing,otherThings.thirdThing'));
expectString(validator('relatedThing,otherThing,otherThings.thirdThing'));
expectString(validator('relatedThing,otherThing,otherThings.thirdThing'));
expectString(validator('relatedThing,otherThing,otherThings.thirdThing'));
expectString(validator('relatedThing,otherThing,otherThings.thirdThing'));
expectString(validator('relatedThing,otherThing,otherThings.thirdThing'));
expectString(validator('relatedThing,otherThing,otherThings.thirdThing'));
expectString(validator('relatedThing,otherThing,otherThings.thirdThing'));

expectNever(
// @ts-expect-error not a valid path since it doesn't exist
validator('not')
);
expectString(validator('relatedThing'));
expectString(validator('relatedThings'));
expectString(validator('otherThing'));
expectString(validator('otherThings'));
expectNever(
// @ts-expect-error not a valid path since its an attribute
validator('name')
);
expectString(validator('otherThing.thirdThing'));
expectString(validator('otherThing.deals'));
expectString(validator('otherThing.original'));
expectNever(
// @ts-expect-error should not include this since original was already processed above
validator('otherThing.original.relatedThing')
);
expectString(validator('otherThing.deep'));
expectString(validator('otherThing.deep.relatedThing'));
expectString(validator('otherThing.deep.otherThing'));
expectString(validator('otherThing.deep.myThing'));
expectString(validator('otherThings.thirdThing'));
expectString(validator('otherThings.deals'));
expectString(validator('otherThings.original'));
expectString(validator('otherThings.deep'));
expectString(validator('otherThings.deep.relatedThing'));

expectNever(
// @ts-expect-error should not include this since original was already processed above
validator('otherThings.deep.relatedThing.relatedThing')
);
expectString(validator('otherThings.deep.otherThing'));
expectString(validator('otherThings.deep.myThing'));
expectString(validator('otherThing.deep.reallyDeepThing'));
expectNever(
// @ts-expect-error should not include this since depth is capped at 3
validator('otherThing.deep.reallyDeepThing.relatedThing')
);

type A = 'hello' | 'there' | 'goodnight' | 'moon';

type V1 = 'hello';
type V2 = 'hello,there';
type V3 = 'there,hello,goodnight';
type V4 = 'moon,there';
type V5 = 'moon,goodnight,hello,there';
type V6 = 'hello,there,goodnight,moon';

type I1 = 'where';
type I2 = 'hello,not';
type I3 = 'invalid,hello,there';
type I4 = 'hello,there,goodnight,moot';
type I5 = 'hello,there,goodnight,moon,invalid';
type I6 = 'hello,there,goodnight,moons';

function ExpectString<T, V extends T>(): V {
return '' as V;
}
function ExpectNever<T, V extends never>(): V {
return '' as V;
}

ExpectString<V1, StringSatisfiesIncludes<V1, A>>();
ExpectString<V2, StringSatisfiesIncludes<V2, A>>();
ExpectString<V3, StringSatisfiesIncludes<V3, A>>();
ExpectString<V4, StringSatisfiesIncludes<V4, A>>();
ExpectString<V5, StringSatisfiesIncludes<V5, A>>();
ExpectString<V6, StringSatisfiesIncludes<V6, A>>();

ExpectNever<I1, StringSatisfiesIncludes<I1, A>>();
ExpectNever<I2, StringSatisfiesIncludes<I2, A>>();
ExpectNever<I3, StringSatisfiesIncludes<I3, A>>();
ExpectNever<I4, StringSatisfiesIncludes<I4, A>>();
ExpectNever<I5, StringSatisfiesIncludes<I5, A>>();
ExpectNever<I6, StringSatisfiesIncludes<I6, A>>();

const foo: StringSatisfiesIncludes<
'otherThings.deep.relatedThing',
Includes<MyThing>
> = 'otherThings.deep.relatedThing';

// @ts-expect-error foo2 is never :)
const foo2: StringSatisfiesIncludes<'company,company.ceo,friends', Includes<MyThing>> = 'company,company.ceo,friends';

expectString(foo);
expectNever(foo2);
Loading

0 comments on commit 55714da

Please sign in to comment.