Skip to content

Commit 8b84580

Browse files
authored
Merge pull request #12 from mizdra/transient-fields
Transient Fields
2 parents 4ed4a14 + 72b86e3 commit 8b84580

File tree

5 files changed

+270
-96
lines changed

5 files changed

+270
-96
lines changed

.eslintrc.cjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module.exports = {
77
parserOptions: {
88
ecmaVersion: 2022,
99
},
10+
reportUnusedDisableDirectives: true,
1011
env: {
1112
es2022: true,
1213
node: true,
@@ -18,6 +19,16 @@ module.exports = {
1819
{
1920
files: ['*.ts', '*.tsx', '*.cts', '*.mts'],
2021
extends: ['@mizdra/mizdra/+typescript', '@mizdra/mizdra/+prettier'],
22+
rules: {
23+
'@typescript-eslint/ban-types': [
24+
'error',
25+
{
26+
types: {
27+
'{}': false,
28+
},
29+
},
30+
],
31+
},
2132
},
2233
],
2334
};

src/field-resolver.test.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,31 +28,36 @@ it('lazy', async () => {
2828
});
2929

3030
it('FieldResolver', () => {
31-
type Type = { a: number };
32-
expectTypeOf<FieldResolver<Type, Type['a']>>().toEqualTypeOf<number | Lazy<{ a: number }, number>>();
31+
type TypeWithTransientFields = { a: number };
32+
expectTypeOf<FieldResolver<TypeWithTransientFields, TypeWithTransientFields['a']>>().toEqualTypeOf<
33+
number | Lazy<{ a: number }, number>
34+
>();
3335
});
3436

3537
it('DefaultFieldsResolver', () => {
36-
type Type1 = { a: number; b: Type2[] };
37-
type Type2 = { c: number };
38-
expectTypeOf<DefaultFieldsResolver<Type1>>().toEqualTypeOf<{
39-
a: number | undefined | Lazy<Type1, number | undefined>;
38+
type Type = { a: number; b: SubType[] };
39+
type SubType = { c: number };
40+
type TransientFields = { _a: number };
41+
expectTypeOf<DefaultFieldsResolver<Type, TransientFields>>().toEqualTypeOf<{
42+
a: number | undefined | Lazy<Type & TransientFields, number | undefined>;
4043
b:
4144
| readonly { readonly c: number | undefined }[]
4245
| undefined
43-
| Lazy<Type1, readonly { readonly c: number | undefined }[] | undefined>;
46+
| Lazy<Type & TransientFields, readonly { readonly c: number | undefined }[] | undefined>;
4447
}>();
4548
});
4649

4750
it('InputFieldsResolver', () => {
48-
type Type1 = { a: number; b: Type2[] };
49-
type Type2 = { c: number };
50-
expectTypeOf<InputFieldsResolver<Type1>>().toEqualTypeOf<{
51-
a?: number | undefined | Lazy<Type1, number | undefined>;
51+
type Type = { a: number; b: SubType[] };
52+
type SubType = { c: number };
53+
type TransientFields = { _a: number };
54+
expectTypeOf<InputFieldsResolver<Type, TransientFields>>().toEqualTypeOf<{
55+
a?: number | undefined | Lazy<Type & TransientFields, number | undefined>;
5256
b?:
5357
| readonly { readonly c: number | undefined }[]
5458
| undefined
55-
| Lazy<Type1, readonly { readonly c: number | undefined }[] | undefined>;
59+
| Lazy<Type & TransientFields, readonly { readonly c: number | undefined }[] | undefined>;
60+
_a?: number | undefined | Lazy<Type & TransientFields, number | undefined>;
5661
}>();
5762
});
5863

src/field-resolver.ts

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,45 @@
11
import { DeepOptional, DeepReadonly, Merge } from './util.js';
22

3-
export type FieldResolverOptions<Type> = {
3+
export type FieldResolverOptions<TypeWithTransientFields> = {
44
seq: number;
5-
get: <FieldName extends keyof Type>(fieldName: FieldName) => Promise<Type[FieldName]>;
5+
get: <FieldName extends keyof TypeWithTransientFields>(
6+
fieldName: FieldName,
7+
) => Promise<TypeWithTransientFields[FieldName]>; // FIXME: return type is wrong
68
};
79

8-
export class Lazy<Type, Field> {
9-
constructor(private readonly factory: (options: FieldResolverOptions<Type>) => Field | Promise<Field>) {}
10-
async get(options: FieldResolverOptions<Type>): Promise<Field> {
10+
export class Lazy<TypeWithTransientFields, Field> {
11+
constructor(
12+
private readonly factory: (options: FieldResolverOptions<TypeWithTransientFields>) => Field | Promise<Field>,
13+
) {}
14+
async get(options: FieldResolverOptions<TypeWithTransientFields>): Promise<Field> {
1115
return this.factory(options);
1216
}
1317
}
1418
/** Wrapper to delay field generation until needed. */
15-
export function lazy<Type, Field>(
16-
factory: (options: FieldResolverOptions<Type>) => Field | Promise<Field>,
17-
): Lazy<Type, Field> {
19+
export function lazy<TypeWithTransientFields, Field>(
20+
factory: (options: FieldResolverOptions<TypeWithTransientFields>) => Field | Promise<Field>,
21+
): Lazy<TypeWithTransientFields, Field> {
1822
return new Lazy(factory);
1923
}
2024

21-
export type FieldResolver<Type, Field> = Field | Lazy<Type, Field>;
25+
export type FieldResolver<TypeWithTransientFields, Field> = Field | Lazy<TypeWithTransientFields, Field>;
2226
/** The type of `defaultFields` option of `defineFactory` function. */
23-
export type DefaultFieldsResolver<Type> = {
24-
[FieldName in keyof Type]: FieldResolver<Type, DeepReadonly<DeepOptional<Type>[FieldName]>>;
27+
export type DefaultFieldsResolver<Type, TransientFields> = {
28+
[FieldName in keyof Type]: FieldResolver<Type & TransientFields, DeepReadonly<DeepOptional<Type>[FieldName]>>;
29+
};
30+
/** The type of `transientFields` option of `defineFactory` function. */
31+
export type TransientFieldsResolver<Type, TransientFields> = {
32+
[FieldName in keyof TransientFields]: FieldResolver<
33+
Type & TransientFields,
34+
DeepReadonly<DeepOptional<TransientFields>[FieldName]>
35+
>;
2536
};
2637
/** The type of `inputFields` option of `build` method. */
27-
export type InputFieldsResolver<Type> = {
28-
[FieldName in keyof Type]?: FieldResolver<Type, DeepReadonly<DeepOptional<Type>[FieldName]>>;
38+
export type InputFieldsResolver<Type, TransientFields> = {
39+
[FieldName in keyof (Type & TransientFields)]?: FieldResolver<
40+
Type & TransientFields,
41+
DeepReadonly<DeepOptional<Type & TransientFields>[FieldName]>
42+
>;
2943
};
3044

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

4054
export async function resolveFields<
4155
Type extends Record<string, unknown>,
42-
_DefaultFieldsResolver extends DefaultFieldsResolver<Type> = DefaultFieldsResolver<Type>,
43-
_InputFieldsResolver extends InputFieldsResolver<Type> = InputFieldsResolver<Type>,
56+
TransientFields extends Record<string, unknown>,
57+
_TransientFieldsResolver extends TransientFieldsResolver<Type, TransientFields>,
58+
_DefaultFieldsResolver extends DefaultFieldsResolver<Type, TransientFields>,
59+
_InputFieldsResolver extends InputFieldsResolver<Type, TransientFields>,
4460
>(
4561
seq: number,
4662
defaultFieldsResolver: _DefaultFieldsResolver,
63+
transientFieldsResolver: _TransientFieldsResolver,
4764
inputFieldsResolver: _InputFieldsResolver,
48-
): Promise<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<_InputFieldsResolver>>> {
65+
): Promise<Merge<ResolvedFields<_DefaultFieldsResolver>, Pick<ResolvedFields<_InputFieldsResolver>, keyof Type>>> {
66+
type TypeWithTransientFields = Type & TransientFields;
67+
4968
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Use any type as it is impossible to match types.
50-
const fields: any = {};
69+
const fields = {} as any;
5170

52-
async function resolveField<Field>(
53-
options: FieldResolverOptions<Type>,
54-
fieldResolver: FieldResolver<Type, Field>,
55-
): Promise<Field> {
71+
async function resolveField<
72+
_FieldResolverOptions extends FieldResolverOptions<TypeWithTransientFields>,
73+
_FieldResolver extends FieldResolver<TypeWithTransientFields, unknown>,
74+
>(options: _FieldResolverOptions, fieldResolver: _FieldResolver): Promise<ResolvedField<_FieldResolver>> {
5675
if (fieldResolver instanceof Lazy) {
5776
return fieldResolver.get(options);
5877
} else {
59-
return fieldResolver;
78+
return fieldResolver as ResolvedField<_FieldResolver>;
6079
}
6180
}
6281

63-
async function resolveFieldAndUpdateCache<FieldName extends keyof Type>(
82+
async function resolveFieldAndUpdateCache<FieldName extends keyof TypeWithTransientFields>(
6483
fieldName: FieldName,
65-
): Promise<Type[FieldName]> {
84+
): Promise<(ResolvedFields<_DefaultFieldsResolver> & ResolvedFields<_InputFieldsResolver>)[FieldName]> {
6685
if (fieldName in fields) return fields[fieldName];
6786

68-
if (fieldName in inputFieldsResolver) {
69-
// 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.
70-
fields[fieldName] = await resolveField(options, inputFieldsResolver[fieldName]);
71-
} else {
72-
// 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.
73-
fields[fieldName] = await resolveField(options, defaultFieldsResolver[fieldName]);
74-
}
87+
const fieldResolver =
88+
fieldName in inputFieldsResolver
89+
? inputFieldsResolver[fieldName as keyof _InputFieldsResolver]
90+
: fieldName in transientFieldsResolver
91+
? transientFieldsResolver[fieldName as keyof _TransientFieldsResolver]
92+
: defaultFieldsResolver[fieldName as keyof _DefaultFieldsResolver];
93+
94+
// eslint-disable-next-line require-atomic-updates
95+
fields[fieldName] = await resolveField(options, fieldResolver);
7596
return fields[fieldName];
7697
}
7798

78-
const options: FieldResolverOptions<Type> = {
99+
const options: FieldResolverOptions<TypeWithTransientFields> = {
79100
seq,
101+
// @ts-expect-error -- FIXME: return type is wrong
80102
get: resolveFieldAndUpdateCache,
81103
};
82104

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

88-
return fields;
110+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Use any type as it is impossible to match types.
111+
return Object.fromEntries(Object.entries(fields).filter(([key]) => key in defaultFieldsResolver)) as any;
89112
}

src/index.test.ts

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { expect, it, describe, assertType, expectTypeOf, vi } from 'vitest';
22
import { oneOf } from './test/util.js';
3-
import { defineBookFactory, resetAllSequence, lazy, defineUserFactory, defineAuthorFactory } from './index.js';
3+
import {
4+
defineBookFactory,
5+
resetAllSequence,
6+
lazy,
7+
defineUserFactory,
8+
defineAuthorFactory,
9+
defineAuthorFactoryWithTransientFields,
10+
} from './index.js';
411

512
describe('integration test', () => {
613
it('circular dependent type', async () => {
@@ -209,8 +216,8 @@ describe('defineTypeFactory', () => {
209216
fullName: lazy(async ({ get }) => `${await get('firstName')} ${await get('lastName')}`),
210217
},
211218
});
212-
const User = await UserFactory.build();
213-
expect(User).toStrictEqual({
219+
const user = await UserFactory.build();
220+
expect(user).toStrictEqual({
214221
id: 'User-0',
215222
firstName: 'Komata',
216223
lastName: 'Mikami',
@@ -221,14 +228,73 @@ describe('defineTypeFactory', () => {
221228
firstName: string;
222229
lastName: string;
223230
fullName: string;
224-
}>(User);
225-
expectTypeOf(User).not.toBeNever();
231+
}>(user);
232+
expectTypeOf(user).not.toBeNever();
226233

227234
// The result of the field resolver is cached, so the resolver is called only once.
228235
expect(firstNameResolver).toHaveBeenCalledTimes(1);
229236
expect(lastNameResolver).toHaveBeenCalledTimes(1);
230237
});
231238
});
239+
describe('transientFields', () => {
240+
it('basic', async () => {
241+
const BookFactory = defineBookFactory({
242+
defaultFields: {
243+
id: lazy(({ seq }) => `Book-${seq}`),
244+
title: lazy(({ seq }) => `ゆゆ式 ${seq}巻`),
245+
author: undefined,
246+
},
247+
});
248+
const AuthorFactory = defineAuthorFactoryWithTransientFields(
249+
{
250+
bookCount: 0,
251+
},
252+
{
253+
defaultFields: {
254+
id: lazy(({ seq }) => `Author-${seq}`),
255+
name: '三上小又',
256+
books: lazy(async ({ get }) => {
257+
const bookCount = await get('bookCount');
258+
// eslint-disable-next-line max-nested-callbacks
259+
return Promise.all(Array.from({ length: bookCount }, async () => BookFactory.build()));
260+
}),
261+
},
262+
},
263+
);
264+
const author1 = await AuthorFactory.build();
265+
expect(author1).toStrictEqual({
266+
id: 'Author-0',
267+
name: '三上小又',
268+
books: [],
269+
});
270+
assertType<{
271+
id: string;
272+
name: string;
273+
books: { id: string; title: string; author: undefined }[];
274+
}>(author1);
275+
expectTypeOf(author1).not.toBeNever();
276+
277+
const author2 = await AuthorFactory.build({ bookCount: 3 });
278+
expect(author2).toStrictEqual({
279+
id: 'Author-1',
280+
name: '三上小又',
281+
books: [
282+
{ id: 'Book-0', title: 'ゆゆ式 0巻', author: undefined },
283+
{ id: 'Book-1', title: 'ゆゆ式 1巻', author: undefined },
284+
{ id: 'Book-2', title: 'ゆゆ式 2巻', author: undefined },
285+
],
286+
});
287+
assertType<{
288+
id: string;
289+
name: string;
290+
books: {
291+
id: string;
292+
title: string;
293+
author: undefined;
294+
}[];
295+
}>(author2);
296+
});
297+
});
232298
describe('resetAllSequence', () => {
233299
it('resets all sequence', async () => {
234300
const BookFactory = defineBookFactory({

0 commit comments

Comments
 (0)