Skip to content

Commit c4db4ba

Browse files
authored
Merge pull request #3403 from SeedCompany/bugfix/null-inputs
2 parents 5084a28 + a24814c commit c4db4ba

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+419
-307
lines changed

src/common/id-field.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import { applyDecorators } from '@nestjs/common';
2-
import { Field, FieldOptions, ID as IDType } from '@nestjs/graphql';
2+
import { ID as IDType } from '@nestjs/graphql';
33
import { ValidationOptions } from 'class-validator';
44
import { IsAny, IsNever, Tagged } from 'type-fest';
55
import type { ResourceName, ResourceNameLike } from '~/core';
6+
import { OptionalField, OptionalFieldOptions } from './optional-field';
67
import { IsId } from './validators';
78

89
export const IdField = ({
910
validation,
1011
...options
11-
}: FieldOptions & { validation?: ValidationOptions } = {}) =>
12+
}: OptionalFieldOptions & { validation?: ValidationOptions } = {}) =>
1213
applyDecorators(
13-
Field(() => IDType, options),
14+
OptionalField(() => IDType, {
15+
optional: false,
16+
...options,
17+
}),
1418
IsId(validation),
1519
);
1620

src/common/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ export * from './fiscal-year';
2424
export * from './generate-id';
2525
export * from './id.arg';
2626
export * from './lazy-ref';
27+
export * from './list-field';
2728
export * from './lazy-record';
2829
export { DateField, DateTimeField } from './luxon.graphql';
2930
export * from './map-or-else';
31+
export * from './optional-field';
3032
export * from './order.enum';
3133
export * from './pagination.input';
3234
export * from './pagination-list';

src/common/list-field.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { applyDecorators } from '@nestjs/common';
2+
import { ArrayNotEmpty } from 'class-validator';
3+
import { OptionalField, OptionalFieldOptions } from './optional-field';
4+
5+
export type ListFieldOptions = OptionalFieldOptions & {
6+
/**
7+
* How should empty lists be handled?
8+
*/
9+
empty?: 'allow' | 'omit' | 'deny';
10+
};
11+
12+
export const ListField = (typeFn: () => any, options: ListFieldOptions) =>
13+
applyDecorators(
14+
OptionalField(() => [typeFn()], {
15+
optional: false,
16+
...options,
17+
transform: (value) => {
18+
let out = value ? [...new Set(value)] : value;
19+
out = options.empty === 'omit' && out.length === 0 ? undefined : out;
20+
return options.transform ? options.transform(out) : out;
21+
},
22+
}),
23+
...(options.empty === 'deny' ? [ArrayNotEmpty()] : []),
24+
);

src/common/luxon.graphql.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { applyDecorators } from '@nestjs/common';
2-
import { CustomScalar, Field, FieldOptions, Scalar } from '@nestjs/graphql';
2+
import { CustomScalar, Scalar } from '@nestjs/graphql';
33
import { stripIndent } from 'common-tags';
44
import { Kind, ValueNode } from 'graphql';
55
import { DateTime, Settings } from 'luxon';
66
import { InputException } from './exceptions';
7+
import { OptionalField, OptionalFieldOptions } from './optional-field';
78
import { CalendarDate } from './temporal';
89
import { Transform } from './transform.decorator';
910
import { ValidateBy } from './validators/validateBy';
@@ -24,9 +25,12 @@ const IsIsoDate = () =>
2425
},
2526
});
2627

27-
export const DateTimeField = (options?: FieldOptions) =>
28+
export const DateTimeField = (options?: OptionalFieldOptions) =>
2829
applyDecorators(
29-
Field(() => DateTime, options),
30+
OptionalField(() => DateTime, {
31+
optional: false,
32+
...options,
33+
}),
3034
Transform(
3135
({ value }) => {
3236
try {
@@ -43,9 +47,12 @@ export const DateTimeField = (options?: FieldOptions) =>
4347
IsIsoDate(),
4448
);
4549

46-
export const DateField = (options?: FieldOptions) =>
50+
export const DateField = (options?: OptionalFieldOptions) =>
4751
applyDecorators(
48-
Field(() => CalendarDate, options),
52+
OptionalField(() => CalendarDate, {
53+
optional: false,
54+
...options,
55+
}),
4956
Transform(
5057
({ value }) => {
5158
try {

src/common/name-field.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,24 @@ import { MinLength } from 'class-validator';
77
import { DbSort } from './db-sort.decorator';
88
import { Transform } from './transform.decorator';
99

10-
export const NameField = (options: FieldOptions = {}) =>
10+
type NameFieldParams = FieldOptions & {
11+
/**
12+
* If true, values can be omitted/undefined or null.
13+
* This will override `optional` if truthy.
14+
*/
15+
nullable?: true;
16+
/**
17+
* If true, values can be omitted/undefined but not null.
18+
*/
19+
optional?: true;
20+
};
21+
22+
export const NameField = (options: NameFieldParams = {}) =>
1123
applyDecorators(
12-
InferredTypeOrStringField(options),
24+
InferredTypeOrStringField({
25+
...options,
26+
nullable: options.optional ?? options.nullable,
27+
}),
1328
Transform(({ value }) => {
1429
if (value === undefined) {
1530
return undefined;
@@ -18,14 +33,16 @@ export const NameField = (options: FieldOptions = {}) =>
1833
// Treat null & empty strings as null
1934
return value?.trim() || null;
2035
}
36+
if (options.optional && value === null) {
37+
// Treat null as an omitted value
38+
return undefined;
39+
}
2140
// Null & empty string treated as MinLength validation error
2241
return value?.trim() ?? '';
2342
}),
2443
DbSort((value) => `apoc.text.clean(${value})`),
2544
// Using this instead of @IsNotEmpty, as this allows nulls.
26-
MinLength(1, {
27-
message: 'Cannot be empty',
28-
}),
45+
MinLength(1, { message: 'Cannot be empty' }),
2946
);
3047

3148
/**

src/common/optional-field.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { applyDecorators } from '@nestjs/common';
2+
import { Field, FieldOptions } from '@nestjs/graphql';
3+
import { NullableList } from '@nestjs/graphql/dist/interfaces/base-type-options.interface';
4+
import { Transform } from 'class-transformer';
5+
6+
export type OptionalFieldOptions = FieldOptions & {
7+
/**
8+
* If true, values can be omitted/undefined or null.
9+
* This will override `optional` if truthy.
10+
*/
11+
nullable?: boolean | NullableList;
12+
/**
13+
* If true, values can be omitted/undefined but not null.
14+
*/
15+
optional?: boolean;
16+
transform?: (value: any) => unknown;
17+
};
18+
19+
/**
20+
* A field that is optional/omissible/can be undefined.
21+
* Whether it can be explicitly null is based on `nullable`.
22+
*/
23+
export function OptionalField(
24+
typeFn: () => any,
25+
options?: OptionalFieldOptions,
26+
): PropertyDecorator;
27+
export function OptionalField(
28+
options?: OptionalFieldOptions,
29+
): PropertyDecorator;
30+
export function OptionalField(...args: any) {
31+
const typeFn: (() => any) | undefined =
32+
typeof args[0] === 'function' ? (args[0] as () => any) : undefined;
33+
const options: OptionalFieldOptions | undefined =
34+
typeof args[0] === 'function' ? args[1] : args[0];
35+
const opts = {
36+
...options,
37+
nullable: options?.nullable ?? options?.optional ?? true,
38+
};
39+
return applyDecorators(
40+
typeFn ? Field(typeFn, opts) : Field(opts),
41+
Transform(({ value }) => {
42+
if (!options?.nullable && (options?.optional ?? true) && value == null) {
43+
return undefined;
44+
}
45+
return options?.transform ? options.transform(value) : value;
46+
}),
47+
);
48+
}

src/common/rich-text.scalar.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { applyDecorators } from '@nestjs/common';
2-
import { Field, FieldOptions, ObjectType } from '@nestjs/graphql';
2+
import { ObjectType } from '@nestjs/graphql';
33
import { setToStringTag } from '@seedcompany/common';
44
import { markSkipClassTransformation } from '@seedcompany/nest';
55
import { IsObject } from 'class-validator';
@@ -9,7 +9,8 @@ import { GraphQLJSONObject } from 'graphql-scalars';
99
import { isEqual } from 'lodash';
1010
import { JsonObject } from 'type-fest';
1111
import { SecuredProperty } from '~/common/secured-property';
12-
import { Transform } from './transform.decorator';
12+
import { InputException } from './exceptions/input.exception';
13+
import { OptionalField, OptionalFieldOptions } from './optional-field';
1314

1415
function hashId(name: string) {
1516
return createHash('shake256', { outputLength: 5 }).update(name).digest('hex');
@@ -76,11 +77,31 @@ export class RichTextDocument {
7677
setToStringTag(RichTextDocument, 'RichText');
7778
markSkipClassTransformation(RichTextDocument);
7879

79-
export const RichTextField = (options?: FieldOptions) =>
80+
export const RichTextField = (options?: OptionalFieldOptions) =>
8081
applyDecorators(
81-
Field(() => RichTextScalar, options),
82+
OptionalField(() => RichTextScalar, {
83+
optional: false,
84+
...options,
85+
transform: (value) => {
86+
const doc = RichTextDocument.fromMaybe(value);
87+
if (doc) {
88+
return doc;
89+
}
90+
if (options?.nullable) {
91+
return null;
92+
}
93+
if (options?.optional) {
94+
return undefined;
95+
}
96+
// Should never _really_ get here.
97+
// UI should understand & send null instead of an empty document.
98+
// Would prefer this to be done with validators.
99+
// But I believe this needs to `null`s to be validated.
100+
// skipMissingProperties -> skipUndefinedProperties
101+
throw new InputException('RichText must be given');
102+
},
103+
}),
82104
IsObject(),
83-
Transform(({ value }) => RichTextDocument.fromMaybe(value)),
84105
);
85106

86107
/** @internal */

src/common/sensitivity.enum.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { applyDecorators } from '@nestjs/common';
2-
import { Field, FieldOptions } from '@nestjs/graphql';
32
import { EnumType, makeEnum } from '@seedcompany/nest';
4-
import { Transform } from 'class-transformer';
5-
import { uniq } from 'lodash';
63
import { rankSens } from '~/core/database/query';
74
import { DbSort } from './db-sort.decorator';
5+
import { ListField, ListFieldOptions } from './list-field';
6+
import { OptionalField, OptionalFieldOptions } from './optional-field';
87

98
export type Sensitivity = EnumType<typeof Sensitivity>;
109
export const Sensitivity = makeEnum({
@@ -13,14 +12,22 @@ export const Sensitivity = makeEnum({
1312
exposeOrder: true,
1413
});
1514

16-
export const SensitivityField = (options: FieldOptions = {}) =>
15+
export const SensitivityField = (options?: OptionalFieldOptions) =>
1716
applyDecorators(
18-
Field(() => Sensitivity, options),
17+
OptionalField(() => Sensitivity, {
18+
optional: false,
19+
...options,
20+
}),
1921
DbSort(rankSens),
2022
);
2123

22-
export const SensitivitiesFilter = () =>
23-
Transform(({ value }) => {
24-
const sens = uniq(value);
25-
return sens.length > 0 && sens.length < 3 ? sens : undefined;
24+
export const SensitivitiesFilterField = (options?: ListFieldOptions) =>
25+
ListField(() => Sensitivity, {
26+
description: 'Only these sensitivities',
27+
...options,
28+
optional: true,
29+
empty: 'omit',
30+
transform: (value) =>
31+
// If given all options, there is no need to filter
32+
!value || value.length === Sensitivity.values.size ? undefined : value,
2633
});

src/components/ceremony/dto/list-ceremony.dto.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { Field, InputType } from '@nestjs/graphql';
2-
import { FilterField, SortablePaginationInput } from '~/common';
1+
import { InputType } from '@nestjs/graphql';
2+
import { FilterField, OptionalField, SortablePaginationInput } from '~/common';
33
import { CeremonyType } from './ceremony-type.enum';
44
import { Ceremony } from './ceremony.dto';
55

66
@InputType()
77
export abstract class CeremonyFilters {
8-
@Field(() => CeremonyType, {
8+
@OptionalField(() => CeremonyType, {
99
description: 'Only ceremonies of this type',
10-
nullable: true,
1110
})
1211
readonly type?: CeremonyType;
1312
}

src/components/ceremony/dto/update-ceremony.dto.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ export abstract class UpdateCeremony {
1313
readonly planned?: boolean;
1414

1515
@DateField({ nullable: true })
16-
readonly estimatedDate?: CalendarDate;
16+
readonly estimatedDate?: CalendarDate | null;
1717

1818
@DateField({ nullable: true })
19-
readonly actualDate?: CalendarDate;
19+
readonly actualDate?: CalendarDate | null;
2020
}
2121

2222
@InputType()

0 commit comments

Comments
 (0)