Skip to content

Commit

Permalink
Merge pull request #45 from mizdra/non-optional-fields
Browse files Browse the repository at this point in the history
Allow omitting fields to be passed to `defaultFields`
  • Loading branch information
mizdra authored Sep 27, 2023
2 parents 57d0d14 + 3f9c4a7 commit 33c091c
Show file tree
Hide file tree
Showing 13 changed files with 129 additions and 31 deletions.
6 changes: 6 additions & 0 deletions e2e/1-basic-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,9 @@ type NamingConventionTest_Type {
type NamingConventionTest_SubType {
field: String!
}

# NonOptionalFields
type NonOptionalFields_OptionalFieldsType {
field1: String!
field2: String!
}
4 changes: 4 additions & 0 deletions e2e/4-non-optional-fields-schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type NonOptionalFields_NonOptionalFieldsType {
field1: String!
field2: String!
}
15 changes: 15 additions & 0 deletions e2e/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ const config: CodegenConfig = {
typesSuffix: 'Suffix',
},
},
'__generated__/4-non-optional-fields/types.ts': {
schema: './4-non-optional-fields-schema.graphql',
plugins: ['typescript'],
config: {
...defaultTypeScriptPluginConfig,
},
},
'./__generated__/4-non-optional-fields/fabbrica.ts': {
schema: './4-non-optional-fields-schema.graphql',
plugins: ['@mizdra/graphql-fabbrica'],
config: {
...defaultFabbricaPluginConfig,
nonOptionalFields: true,
},
},
},
};

Expand Down
30 changes: 30 additions & 0 deletions e2e/index.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import {
defineNamingConventionTest_RenamedTypeFactory,
defineNullableTest_TypeFactory,
defineInputTest_InputFactory,
defineNonOptionalFields_OptionalFieldsTypeFactory,
} from './__generated__/1-basic/fabbrica.js';
import { oneOf } from './test/util.js';
import { definePrefixTypeFactory } from './__generated__/2-typesPrefix/fabbrica.js';
import { defineTypeSuffixFactory } from './__generated__/3-typesSuffix/fabbrica.js';
import { defineNonOptionalFields_NonOptionalFieldsTypeFactory } from './__generated__/4-non-optional-fields/fabbrica.js';

describe('integration test', () => {
it('circular dependent type', async () => {
Expand Down Expand Up @@ -408,6 +410,34 @@ describe('defineTypeFactory', () => {
expect(firstNameResolver).toHaveBeenCalledTimes(1);
expect(lastNameResolver).toHaveBeenCalledTimes(1);
});
describe('nonOptionalFields', () => {
it('requires to pass all fields if nonOptionalFields is false', async () => {
defineNonOptionalFields_NonOptionalFieldsTypeFactory({
// @ts-expect-error -- expects error
defaultFields: {
field1: 'field1',
// field2: 'field2',
},
});
});
it('requires to pass all fields if nonOptionalFields is true', async () => {
const TypeFactory = defineNonOptionalFields_OptionalFieldsTypeFactory({
defaultFields: {
field1: 'field1',
// field2: 'field2',
},
});
// field2 is not included if it is not passed to `defaultFields` or `build`.
const type1 = await TypeFactory.build();
expect(type1).toStrictEqual({ field1: 'field1' });
expectTypeOf(type1).toEqualTypeOf<{ field1: string }>();

// field2 is included if it is passed to `defaultFields` or `build`.
const type2 = await TypeFactory.build({ field2: 'field2' });
expect(type2).toStrictEqual({ field1: 'field1', field2: 'field2' });
expectTypeOf(type2).toEqualTypeOf<{ field1: string; field2: string }>();
});
});
});
describe('traits', () => {
it('overrides defaultFields', async () => {
Expand Down
11 changes: 7 additions & 4 deletions src/code-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ function generateFieldNamesDefinitionCode(typeInfo: TypeInfo): string {
return `const ${name}FieldNames = [${joinedFieldNames}] as const;\n`;
}

function generateTypeFactoryCode(typeInfo: TypeInfo): string {
function generateTypeFactoryCode(config: Config, typeInfo: TypeInfo): string {
const { name } = typeInfo;
function wrapRequired(str: string) {
return config.nonOptionalFields ? `Required<${str}>` : str;
}
return `
export type ${name}FactoryDefineOptions<
TransientFields extends Record<string, unknown>,
Expand All @@ -57,7 +60,7 @@ export type ${name}FactoryInterface<
export function define${name}FactoryInternal<
TransientFields extends Record<string, unknown>,
_DefaultFieldsResolver extends DefaultFieldsResolver<Optional${name} & TransientFields>,
_DefaultFieldsResolver extends ${wrapRequired(`DefaultFieldsResolver<Optional${name} & TransientFields>`)},
_Traits extends Traits<Optional${name}, TransientFields>,
>(
options: ${name}FactoryDefineOptions<TransientFields, _DefaultFieldsResolver, _Traits>,
Expand All @@ -72,7 +75,7 @@ export function define${name}FactoryInternal<
* @returns factory {@link ${name}FactoryInterface}
*/
export function define${name}Factory<
_DefaultFieldsResolver extends DefaultFieldsResolver<Optional${name}>,
_DefaultFieldsResolver extends ${wrapRequired(`DefaultFieldsResolver<Optional${name}>`)},
_Traits extends Traits<Optional${name}, {}>,
>(
options: ${name}FactoryDefineOptions<{}, _DefaultFieldsResolver, _Traits>,
Expand All @@ -90,7 +93,7 @@ export function generateCode(config: Config, typeInfos: TypeInfo[]): string {
code += '\n';
code += generateFieldNamesDefinitionCode(typeInfo);
code += '\n';
code += generateTypeFactoryCode(typeInfo);
code += generateTypeFactoryCode(config, typeInfo);
code += '\n';
}
return code;
Expand Down
7 changes: 7 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ describe('validateConfig', () => {
'`options.skipIsAbstractType` must be a boolean',
);
});
it('nonOptionalFields', () => {
expect(() => validateConfig({ typesFile: './types', nonOptionalFields: oneOf([true, false]) })).not.toThrow();
expect(() => validateConfig({ typesFile: './types' })).not.toThrow();
expect(() => validateConfig({ typesFile: './types', nonOptionalFields: 1 })).toThrow(
'`options.nonOptionalFields` must be a boolean',
);
});
it('typesPrefix', () => {
expect(() => validateConfig({ typesFile: './types', typesPrefix: 'Prefix' })).not.toThrow();
expect(() => validateConfig({ typesFile: './types' })).not.toThrow();
Expand Down
11 changes: 9 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type RawConfig = {
typesFile: string;
skipTypename?: RawTypesConfig['skipTypename'];
skipIsAbstractType?: boolean | undefined;
nonOptionalFields?: boolean | undefined;
namingConvention?: RawTypesConfig['namingConvention'];
typesPrefix?: RawTypesConfig['typesPrefix'];
typesSuffix?: RawTypesConfig['typesSuffix'];
Expand All @@ -14,6 +15,7 @@ export type Config = {
typesFile: string;
skipTypename: Exclude<RawTypesConfig['skipTypename'], undefined>;
skipIsAbstractType: boolean;
nonOptionalFields: boolean;
typesPrefix: Exclude<RawTypesConfig['typesPrefix'], undefined>;
typesSuffix: Exclude<RawTypesConfig['typesSuffix'], undefined>;
convert: ConvertFn;
Expand All @@ -36,6 +38,9 @@ export function validateConfig(rawConfig: unknown): asserts rawConfig is RawConf
if ('skipIsAbstractType' in rawConfig && typeof rawConfig['skipIsAbstractType'] !== 'boolean') {
throw new Error('`options.skipIsAbstractType` must be a boolean');
}
if ('nonOptionalFields' in rawConfig && typeof rawConfig['nonOptionalFields'] !== 'boolean') {
throw new Error('`options.nonOptionalFields` must be a boolean');
}
if ('typesPrefix' in rawConfig && typeof rawConfig['typesPrefix'] !== 'string') {
throw new Error('`options.typesPrefix` must be a string');
}
Expand All @@ -49,9 +54,11 @@ export function normalizeConfig(rawConfig: RawConfig): Config {
typesFile: rawConfig.typesFile,
skipTypename: rawConfig.skipTypename ?? false,
skipIsAbstractType: rawConfig.skipIsAbstractType ?? true,
nonOptionalFields: rawConfig.nonOptionalFields ?? false,
typesPrefix: rawConfig.typesPrefix ?? '',
typesSuffix: rawConfig.typesSuffix ?? '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
convert: convertFactory(rawConfig as any),
convert: rawConfig.namingConvention
? convertFactory({ namingConvention: rawConfig.namingConvention })
: convertFactory({}),
};
}
23 changes: 14 additions & 9 deletions src/helper/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
FieldResolver,
} from './field-resolver.js';
import { getSequenceCounter, resetSequence } from './sequence.js';
import { Merge } from './util.js';
import { Merge, StrictlyPick } from './util.js';

export type Traits<OptionalType extends Record<string, unknown>, TransientFields extends Record<string, unknown>> = {
[traitName: string]: {
Expand All @@ -30,20 +30,22 @@ export interface TypeFactoryInterface<
OptionalType extends Record<string, unknown>,
TransientFields extends Record<string, unknown>,
// NOTE: The constraints of _DefaultFieldsResolver are loose so that `Merge<_DefaultFieldsResolver, _Traits[T]['defaultFields']>` is accepted.
_DefaultFieldsResolver extends Record<keyof OptionalType, FieldResolver<OptionalType & TransientFields, unknown>>,
_DefaultFieldsResolver extends Partial<
Record<keyof OptionalType, FieldResolver<OptionalType & TransientFields, unknown>>
>,
_Traits extends Traits<OptionalType, TransientFields>,
> {
build(): Promise<Pick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<{}>>, keyof OptionalType>>;
build(): Promise<StrictlyPick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<{}>>, keyof OptionalType>>;
build<T extends InputFieldsResolver<OptionalType & TransientFields>>(
inputFieldsResolver: T,
): Promise<Pick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<T>>, keyof OptionalType>>;
): Promise<StrictlyPick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<T>>, keyof OptionalType>>;
buildList(
count: number,
): Promise<Pick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<{}>>, keyof OptionalType>[]>;
): Promise<StrictlyPick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<{}>>, keyof OptionalType>[]>;
buildList<T extends InputFieldsResolver<OptionalType & TransientFields>>(
count: number,
inputFieldsResolver: T,
): Promise<Pick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<T>>, keyof OptionalType>[]>;
): Promise<StrictlyPick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<T>>, keyof OptionalType>[]>;
use<T extends keyof _Traits>(
traitName: T,
): TypeFactoryInterface<
Expand Down Expand Up @@ -72,7 +74,7 @@ export function defineTypeFactoryInternal<
return {
async build<T extends InputFieldsResolver<OptionalType & TransientFields>>(
inputFieldsResolver?: T,
): Promise<Pick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<T>>, keyof OptionalType>> {
): Promise<StrictlyPick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<T>>, keyof OptionalType>> {
const seq = getSeq();
return resolveFields<OptionalType, TransientFields, _DefaultFieldsResolver, T>(
typeFieldNames,
Expand All @@ -84,8 +86,11 @@ export function defineTypeFactoryInternal<
async buildList<T extends InputFieldsResolver<OptionalType & TransientFields>>(
count: number,
inputFieldsResolver?: T,
): Promise<Pick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<T>>, keyof OptionalType>[]> {
const array: Pick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<T>>, keyof OptionalType>[] = [];
): Promise<StrictlyPick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<T>>, keyof OptionalType>[]> {
const array: StrictlyPick<
Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<T>>,
keyof OptionalType
>[] = [];
for (let i = 0; i < count; i++) {
if (inputFieldsResolver) {
// eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-explicit-any
Expand Down
4 changes: 2 additions & 2 deletions src/helper/field-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ it('DefaultFieldsResolver', () => {
type OptionalTypeWithTransientFields = { a: number | undefined; b: OptionalSubType[] | undefined };
type OptionalSubType = { c: number | undefined };
expectTypeOf<DefaultFieldsResolver<OptionalTypeWithTransientFields>>().toEqualTypeOf<{
a: number | undefined | Dynamic<OptionalTypeWithTransientFields, number | undefined>;
b:
a?: number | undefined | Dynamic<OptionalTypeWithTransientFields, number | undefined>;
b?:
| readonly { readonly c: number | undefined }[]
| undefined
| Dynamic<OptionalTypeWithTransientFields, readonly { readonly c: number | undefined }[] | undefined>;
Expand Down
29 changes: 16 additions & 13 deletions src/helper/field-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { DeepReadonly, Merge } from './util.js';
import { DeepReadonly, Merge, StrictlyPick } from './util.js';

export type FieldResolverOptions<OptionalTypeWithTransientFields> = {
seq: number;
get: <FieldName extends keyof OptionalTypeWithTransientFields>(
fieldName: FieldName,
) => Promise<DeepReadonly<OptionalTypeWithTransientFields[FieldName]>>;
) => Promise<DeepReadonly<OptionalTypeWithTransientFields[FieldName]> | undefined>;
};

export class Dynamic<OptionalTypeWithTransientFields, Field> {
Expand All @@ -29,15 +29,14 @@ export type FieldResolver<OptionalTypeWithTransientFields, Field> =
| Dynamic<OptionalTypeWithTransientFields, Field>;
/** The type of `defaultFields` option of `defineFactory` function. */
export type DefaultFieldsResolver<OptionalTypeWithTransientFields> = {
[FieldName in keyof OptionalTypeWithTransientFields]: FieldResolver<
[FieldName in keyof OptionalTypeWithTransientFields]?: FieldResolver<
OptionalTypeWithTransientFields,
DeepReadonly<OptionalTypeWithTransientFields[FieldName]>
>;
};
/** The type of `inputFields` option of `build` method. */
export type InputFieldsResolver<OptionalTypeWithTransientFields> = Partial<
DefaultFieldsResolver<OptionalTypeWithTransientFields>
>;
export type InputFieldsResolver<OptionalTypeWithTransientFields> =
DefaultFieldsResolver<OptionalTypeWithTransientFields>;

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type ResolvedField<T extends FieldResolver<unknown, unknown>> = T extends FieldResolver<infer _, infer R>
Expand All @@ -59,7 +58,7 @@ export async function resolveFields<
defaultFieldsResolver: _DefaultFieldsResolver,
inputFieldsResolver: _InputFieldsResolver,
): Promise<
Pick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<_InputFieldsResolver>>, keyof OptionalType>
StrictlyPick<Merge<ResolvedFields<_DefaultFieldsResolver>, ResolvedFields<_InputFieldsResolver>>, keyof OptionalType>
> {
type OptionalTypeWithTransientFields = OptionalType & TransientFields;

Expand All @@ -79,13 +78,17 @@ export async function resolveFields<

async function resolveFieldAndUpdateCache<FieldName extends keyof OptionalTypeWithTransientFields>(
fieldName: FieldName,
): Promise<DeepReadonly<OptionalTypeWithTransientFields[FieldName]>> {
): Promise<DeepReadonly<OptionalTypeWithTransientFields[FieldName]> | undefined> {
if (fieldName in fields) return fields[fieldName];

const fieldResolver =
fieldName in inputFieldsResolver
? inputFieldsResolver[fieldName as keyof _InputFieldsResolver]
: defaultFieldsResolver[fieldName as keyof _DefaultFieldsResolver];
let fieldResolver: FieldResolver<OptionalType & TransientFields, unknown>;
if (fieldName in inputFieldsResolver) {
fieldResolver = inputFieldsResolver[fieldName as keyof _InputFieldsResolver];
} else if (fieldName in defaultFieldsResolver) {
fieldResolver = defaultFieldsResolver[fieldName as keyof _DefaultFieldsResolver];
} else {
return undefined;
}

// eslint-disable-next-line require-atomic-updates
fields[fieldName] = await resolveField(options, fieldResolver);
Expand All @@ -97,7 +100,7 @@ export async function resolveFields<
get: resolveFieldAndUpdateCache,
};

for (const fieldName of Object.keys(defaultFieldsResolver) as (keyof OptionalType)[]) {
for (const fieldName of fieldNames) {
// eslint-disable-next-line no-await-in-loop
await resolveFieldAndUpdateCache(fieldName);
}
Expand Down
12 changes: 11 additions & 1 deletion src/helper/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expectTypeOf, it } from 'vitest';
import { DeepReadonly, type DeepOptional, type Merge } from './util.js';
import { DeepReadonly, type DeepOptional, type Merge, StrictlyPick } from './util.js';

it('DeepOptional', () => {
type Input = {
Expand Down Expand Up @@ -45,3 +45,13 @@ it('Merge', () => {
d: string;
}>();
});

it('StrictlyPick', () => {
expectTypeOf<StrictlyPick<{ a: number; b: number; c: number }, 'a' | 'b'>>().toEqualTypeOf<{
a: number;
b: number;
}>();
expectTypeOf<StrictlyPick<{ a: number; c: number }, 'a' | 'b'>>().toEqualTypeOf<{
a: number;
}>();
});
7 changes: 7 additions & 0 deletions src/helper/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,10 @@ export type Merge<F, S> = {
? F[K]
: never;
};

/**
* @example `StrictlyPick<{ a: number, c: number }, 'a' | 'b'>` is `{ a: number }`.
*/
export type StrictlyPick<T, K> = {
[P in K & keyof T]: T[P];
};
1 change: 1 addition & 0 deletions src/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function fakeConfig(args: Partial<Config> = {}): Config {
typesFile: './types',
skipTypename: true,
skipIsAbstractType: true,
nonOptionalFields: false,
typesPrefix: '',
typesSuffix: '',
convert: convertFactory({}),
Expand Down

0 comments on commit 33c091c

Please sign in to comment.