Skip to content

Commit

Permalink
Merge pull request #12 from mizdra/transient-fields
Browse files Browse the repository at this point in the history
Transient Fields
  • Loading branch information
mizdra committed Aug 27, 2023
2 parents 4ed4a14 + 72b86e3 commit 8b84580
Show file tree
Hide file tree
Showing 5 changed files with 270 additions and 96 deletions.
11 changes: 11 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = {
parserOptions: {
ecmaVersion: 2022,
},
reportUnusedDisableDirectives: true,
env: {
es2022: true,
node: true,
Expand All @@ -18,6 +19,16 @@ module.exports = {
{
files: ['*.ts', '*.tsx', '*.cts', '*.mts'],
extends: ['@mizdra/mizdra/+typescript', '@mizdra/mizdra/+prettier'],
rules: {
'@typescript-eslint/ban-types': [
'error',
{
types: {
'{}': false,
},
},
],
},
},
],
};
29 changes: 17 additions & 12 deletions src/field-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,36 @@ it('lazy', async () => {
});

it('FieldResolver', () => {
type Type = { a: number };
expectTypeOf<FieldResolver<Type, Type['a']>>().toEqualTypeOf<number | Lazy<{ a: number }, number>>();
type TypeWithTransientFields = { a: number };
expectTypeOf<FieldResolver<TypeWithTransientFields, TypeWithTransientFields['a']>>().toEqualTypeOf<
number | Lazy<{ a: number }, number>
>();
});

it('DefaultFieldsResolver', () => {
type Type1 = { a: number; b: Type2[] };
type Type2 = { c: number };
expectTypeOf<DefaultFieldsResolver<Type1>>().toEqualTypeOf<{
a: number | undefined | Lazy<Type1, number | undefined>;
type Type = { a: number; b: SubType[] };
type SubType = { c: number };
type TransientFields = { _a: number };
expectTypeOf<DefaultFieldsResolver<Type, TransientFields>>().toEqualTypeOf<{
a: number | undefined | Lazy<Type & TransientFields, number | undefined>;
b:
| readonly { readonly c: number | undefined }[]
| undefined
| Lazy<Type1, readonly { readonly c: number | undefined }[] | undefined>;
| Lazy<Type & TransientFields, readonly { readonly c: number | undefined }[] | undefined>;
}>();
});

it('InputFieldsResolver', () => {
type Type1 = { a: number; b: Type2[] };
type Type2 = { c: number };
expectTypeOf<InputFieldsResolver<Type1>>().toEqualTypeOf<{
a?: number | undefined | Lazy<Type1, number | undefined>;
type Type = { a: number; b: SubType[] };
type SubType = { c: number };
type TransientFields = { _a: number };
expectTypeOf<InputFieldsResolver<Type, TransientFields>>().toEqualTypeOf<{
a?: number | undefined | Lazy<Type & TransientFields, number | undefined>;
b?:
| readonly { readonly c: number | undefined }[]
| undefined
| Lazy<Type1, readonly { readonly c: number | undefined }[] | undefined>;
| Lazy<Type & TransientFields, readonly { readonly c: number | undefined }[] | undefined>;
_a?: number | undefined | Lazy<Type & TransientFields, number | undefined>;
}>();
});

Expand Down
89 changes: 56 additions & 33 deletions src/field-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,45 @@
import { DeepOptional, DeepReadonly, Merge } from './util.js';

export type FieldResolverOptions<Type> = {
export type FieldResolverOptions<TypeWithTransientFields> = {
seq: number;
get: <FieldName extends keyof Type>(fieldName: FieldName) => Promise<Type[FieldName]>;
get: <FieldName extends keyof TypeWithTransientFields>(
fieldName: FieldName,
) => Promise<TypeWithTransientFields[FieldName]>; // FIXME: return type is wrong
};

export class Lazy<Type, Field> {
constructor(private readonly factory: (options: FieldResolverOptions<Type>) => Field | Promise<Field>) {}
async get(options: FieldResolverOptions<Type>): Promise<Field> {
export class Lazy<TypeWithTransientFields, Field> {
constructor(
private readonly factory: (options: FieldResolverOptions<TypeWithTransientFields>) => Field | Promise<Field>,
) {}
async get(options: FieldResolverOptions<TypeWithTransientFields>): Promise<Field> {
return this.factory(options);
}
}
/** Wrapper to delay field generation until needed. */
export function lazy<Type, Field>(
factory: (options: FieldResolverOptions<Type>) => Field | Promise<Field>,
): Lazy<Type, Field> {
export function lazy<TypeWithTransientFields, Field>(
factory: (options: FieldResolverOptions<TypeWithTransientFields>) => Field | Promise<Field>,
): Lazy<TypeWithTransientFields, Field> {
return new Lazy(factory);
}

export type FieldResolver<Type, Field> = Field | Lazy<Type, Field>;
export type FieldResolver<TypeWithTransientFields, Field> = Field | Lazy<TypeWithTransientFields, Field>;
/** The type of `defaultFields` option of `defineFactory` function. */
export type DefaultFieldsResolver<Type> = {
[FieldName in keyof Type]: FieldResolver<Type, DeepReadonly<DeepOptional<Type>[FieldName]>>;
export type DefaultFieldsResolver<Type, TransientFields> = {
[FieldName in keyof Type]: FieldResolver<Type & TransientFields, DeepReadonly<DeepOptional<Type>[FieldName]>>;
};
/** The type of `transientFields` option of `defineFactory` function. */
export type TransientFieldsResolver<Type, TransientFields> = {
[FieldName in keyof TransientFields]: FieldResolver<
Type & TransientFields,
DeepReadonly<DeepOptional<TransientFields>[FieldName]>
>;
};
/** The type of `inputFields` option of `build` method. */
export type InputFieldsResolver<Type> = {
[FieldName in keyof Type]?: FieldResolver<Type, DeepReadonly<DeepOptional<Type>[FieldName]>>;
export type InputFieldsResolver<Type, TransientFields> = {
[FieldName in keyof (Type & TransientFields)]?: FieldResolver<
Type & TransientFields,
DeepReadonly<DeepOptional<Type & TransientFields>[FieldName]>
>;
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -39,44 +53,52 @@ export type ResolvedFields<FieldsResolver extends Record<string, FieldResolver<u

export async function resolveFields<
Type extends Record<string, unknown>,
_DefaultFieldsResolver extends DefaultFieldsResolver<Type> = DefaultFieldsResolver<Type>,
_InputFieldsResolver extends InputFieldsResolver<Type> = InputFieldsResolver<Type>,
TransientFields extends Record<string, unknown>,
_TransientFieldsResolver extends TransientFieldsResolver<Type, TransientFields>,
_DefaultFieldsResolver extends DefaultFieldsResolver<Type, TransientFields>,
_InputFieldsResolver extends InputFieldsResolver<Type, TransientFields>,
>(
seq: number,
defaultFieldsResolver: _DefaultFieldsResolver,
transientFieldsResolver: _TransientFieldsResolver,
inputFieldsResolver: _InputFieldsResolver,
): Promise<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<_InputFieldsResolver>>> {
): Promise<Merge<ResolvedFields<_DefaultFieldsResolver>, Pick<ResolvedFields<_InputFieldsResolver>, keyof Type>>> {
type TypeWithTransientFields = Type & TransientFields;

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Use any type as it is impossible to match types.
const fields: any = {};
const fields = {} as any;

async function resolveField<Field>(
options: FieldResolverOptions<Type>,
fieldResolver: FieldResolver<Type, Field>,
): Promise<Field> {
async function resolveField<
_FieldResolverOptions extends FieldResolverOptions<TypeWithTransientFields>,
_FieldResolver extends FieldResolver<TypeWithTransientFields, unknown>,
>(options: _FieldResolverOptions, fieldResolver: _FieldResolver): Promise<ResolvedField<_FieldResolver>> {
if (fieldResolver instanceof Lazy) {
return fieldResolver.get(options);
} else {
return fieldResolver;
return fieldResolver as ResolvedField<_FieldResolver>;
}
}

async function resolveFieldAndUpdateCache<FieldName extends keyof Type>(
async function resolveFieldAndUpdateCache<FieldName extends keyof TypeWithTransientFields>(
fieldName: FieldName,
): Promise<Type[FieldName]> {
): Promise<(ResolvedFields<_DefaultFieldsResolver> & ResolvedFields<_InputFieldsResolver>)[FieldName]> {
if (fieldName in fields) return fields[fieldName];

if (fieldName in inputFieldsResolver) {
// eslint-disable-next-line require-atomic-updates, no-await-in-loop -- The fields are resolved sequentially, so there is no possibility of a race condition.
fields[fieldName] = await resolveField(options, inputFieldsResolver[fieldName]);
} else {
// eslint-disable-next-line require-atomic-updates, no-await-in-loop -- The fields are resolved sequentially, so there is no possibility of a race condition.
fields[fieldName] = await resolveField(options, defaultFieldsResolver[fieldName]);
}
const fieldResolver =
fieldName in inputFieldsResolver
? inputFieldsResolver[fieldName as keyof _InputFieldsResolver]
: fieldName in transientFieldsResolver
? transientFieldsResolver[fieldName as keyof _TransientFieldsResolver]
: defaultFieldsResolver[fieldName as keyof _DefaultFieldsResolver];

// eslint-disable-next-line require-atomic-updates
fields[fieldName] = await resolveField(options, fieldResolver);
return fields[fieldName];
}

const options: FieldResolverOptions<Type> = {
const options: FieldResolverOptions<TypeWithTransientFields> = {
seq,
// @ts-expect-error -- FIXME: return type is wrong
get: resolveFieldAndUpdateCache,
};

Expand All @@ -85,5 +107,6 @@ export async function resolveFields<
await resolveFieldAndUpdateCache(fieldName);
}

return fields;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Use any type as it is impossible to match types.
return Object.fromEntries(Object.entries(fields).filter(([key]) => key in defaultFieldsResolver)) as any;
}
76 changes: 71 additions & 5 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { expect, it, describe, assertType, expectTypeOf, vi } from 'vitest';
import { oneOf } from './test/util.js';
import { defineBookFactory, resetAllSequence, lazy, defineUserFactory, defineAuthorFactory } from './index.js';
import {
defineBookFactory,
resetAllSequence,
lazy,
defineUserFactory,
defineAuthorFactory,
defineAuthorFactoryWithTransientFields,
} from './index.js';

describe('integration test', () => {
it('circular dependent type', async () => {
Expand Down Expand Up @@ -209,8 +216,8 @@ describe('defineTypeFactory', () => {
fullName: lazy(async ({ get }) => `${await get('firstName')} ${await get('lastName')}`),
},
});
const User = await UserFactory.build();
expect(User).toStrictEqual({
const user = await UserFactory.build();
expect(user).toStrictEqual({
id: 'User-0',
firstName: 'Komata',
lastName: 'Mikami',
Expand All @@ -221,14 +228,73 @@ describe('defineTypeFactory', () => {
firstName: string;
lastName: string;
fullName: string;
}>(User);
expectTypeOf(User).not.toBeNever();
}>(user);
expectTypeOf(user).not.toBeNever();

// The result of the field resolver is cached, so the resolver is called only once.
expect(firstNameResolver).toHaveBeenCalledTimes(1);
expect(lastNameResolver).toHaveBeenCalledTimes(1);
});
});
describe('transientFields', () => {
it('basic', async () => {
const BookFactory = defineBookFactory({
defaultFields: {
id: lazy(({ seq }) => `Book-${seq}`),
title: lazy(({ seq }) => `ゆゆ式 ${seq}巻`),
author: undefined,
},
});
const AuthorFactory = defineAuthorFactoryWithTransientFields(
{
bookCount: 0,
},
{
defaultFields: {
id: lazy(({ seq }) => `Author-${seq}`),
name: '三上小又',
books: lazy(async ({ get }) => {
const bookCount = await get('bookCount');
// eslint-disable-next-line max-nested-callbacks
return Promise.all(Array.from({ length: bookCount }, async () => BookFactory.build()));
}),
},
},
);
const author1 = await AuthorFactory.build();
expect(author1).toStrictEqual({
id: 'Author-0',
name: '三上小又',
books: [],
});
assertType<{
id: string;
name: string;
books: { id: string; title: string; author: undefined }[];
}>(author1);
expectTypeOf(author1).not.toBeNever();

const author2 = await AuthorFactory.build({ bookCount: 3 });
expect(author2).toStrictEqual({
id: 'Author-1',
name: '三上小又',
books: [
{ id: 'Book-0', title: 'ゆゆ式 0巻', author: undefined },
{ id: 'Book-1', title: 'ゆゆ式 1巻', author: undefined },
{ id: 'Book-2', title: 'ゆゆ式 2巻', author: undefined },
],
});
assertType<{
id: string;
name: string;
books: {
id: string;
title: string;
author: undefined;
}[];
}>(author2);
});
});
describe('resetAllSequence', () => {
it('resets all sequence', async () => {
const BookFactory = defineBookFactory({
Expand Down
Loading

0 comments on commit 8b84580

Please sign in to comment.