Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(matchedData): add discardUndefined option when selecting fields #1275

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
19 changes: 19 additions & 0 deletions docs/api/matched-data.md
Expand Up @@ -55,6 +55,25 @@ app.post(
);
```

If you want to return optional values which aren't `undefined` like `null` and `falsy` values,
you can set `options.includeOptionals` to `discardUndefined`.

```ts
app.post(
'/contact-us',
[
body('email').isEmail(),
body('message').notEmpty(),
body('phone').optional({ values: 'null' }).isMobilePhone(),
],
(req, res) => {
const data = matchedData(req, { includeOptionals: 'discardUndefined' });
// If phone is set as null:
// => { email: '[email protected]', message: 'Hi hello', phone: null }
},
);
```

:::tip

See the [documentation on `.optional()`](./validation-chain.md#optional) to learn more.
Expand Down
10 changes: 10 additions & 0 deletions src/context.spec.ts
Vladyslav531 marked this conversation as resolved.
Show resolved Hide resolved
Expand Up @@ -310,6 +310,16 @@ describe('#getData()', () => {
expect(context.getData({ requiredOnly: true })).toEqual([]);
});

it('includes null values when `discardUndefined` is used', () => {
data[0].value = null;
data[1].value = undefined;

context = new ContextBuilder().setOptional('null').build();
context.addFieldInstances(data);

expect(context.getData({ includeOptionals: 'discardUndefined' })).toEqual([data[0]]);
});

it('filters out falsies when context optional = falsy', () => {
data[0].value = null;
data[1].value = undefined;
Expand Down
27 changes: 25 additions & 2 deletions src/context.ts
Expand Up @@ -25,6 +25,15 @@ function getDataMapKey(path: string, location: Location) {
*/
export type Optional = 'undefined' | 'null' | 'falsy' | false;

/**
* Defines if includes optional data or not.
*
* - false: do not include optional data
* - true: include optional data
* - `discardUndefined`: include optional data except for undefined values
*/
export type IncludeOptionals = boolean | 'discardUndefined';

type AddErrorOptions =
| {
type: 'field';
Expand Down Expand Up @@ -67,15 +76,29 @@ export class Context {
readonly message?: any,
) {}

getData(options: { requiredOnly: boolean } = { requiredOnly: false }) {
// TODO(8.0.0): remove requiredOnly
getData(
options: {
/**
* It is ignored if `includeOptionals` is specified
* @deprecated use `includeOptionals` instead
*/
requiredOnly?: boolean;
includeOptionals?: IncludeOptionals;
} = { requiredOnly: false },
) {
const { optional } = this;
const includeOptionals: IncludeOptionals =
options.includeOptionals !== undefined ? options.includeOptionals : !options.requiredOnly;
const checks =
options.requiredOnly && optional
!includeOptionals && optional
? [
(value: any) => value !== undefined,
(value: any) => (optional === 'null' ? value != null : true),
(value: any) => (optional === 'falsy' ? value : true),
]
: includeOptionals === 'discardUndefined' && optional
? [(value: any) => value !== undefined]
: [];

return _([...this.dataMap.values()])
Expand Down
19 changes: 19 additions & 0 deletions src/matched-data.spec.ts
Expand Up @@ -73,6 +73,25 @@ describe('when option includeOptionals is true', () => {
});
});

describe('when option includeOptionals is discardUndefined ', () => {
it('returns object with optional data which is not undefined', done => {
const req = {
headers: { foo: '123', bar: null },
};

const middleware = check(['foo', 'bar', 'baz']).optional({ values: 'null' }).isInt();

middleware(req, {}, () => {
const data = matchedData(req, { includeOptionals: 'discardUndefined' });
expect(data).toHaveProperty('foo', '123');
expect(data).toHaveProperty('bar', null);
expect(data).not.toHaveProperty('baz');

done();
});
});
fedeci marked this conversation as resolved.
Show resolved Hide resolved
});

describe('when option onlyValidData is false', () => {
it('returns object with invalid data', done => {
const req = {
Expand Down
15 changes: 8 additions & 7 deletions src/matched-data.ts
@@ -1,6 +1,6 @@
import * as _ from 'lodash';
import { FieldInstance, InternalRequest, Location, Request, contextsKey } from './base';
import { Context } from './context';
import { Context, IncludeOptionals } from './context';

interface FieldInstanceBag {
instance: FieldInstance;
Expand All @@ -12,7 +12,7 @@ export type MatchedDataOptions = {
* Whether the value returned by `matchedData()` should include data deemed optional.
* @default false
*/
includeOptionals: boolean;
includeOptionals: IncludeOptionals;

/**
* An array of locations in the request to extract the data from.
Expand All @@ -39,10 +39,11 @@ export function matchedData<T extends object = Record<string, any>>(
options: Partial<MatchedDataOptions> = {},
): T {
const internalReq: InternalRequest = req;
const { includeOptionals = false, onlyValidData, locations } = options;

const fieldExtractor = createFieldExtractor(options.includeOptionals !== true);
const validityFilter = createValidityFilter(options.onlyValidData);
const locationFilter = createLocationFilter(options.locations);
const fieldExtractor = createFieldExtractor(includeOptionals);
const validityFilter = createValidityFilter(onlyValidData);
const locationFilter = createLocationFilter(locations);

return _(internalReq[contextsKey])
.flatMap(fieldExtractor)
Expand All @@ -52,9 +53,9 @@ export function matchedData<T extends object = Record<string, any>>(
.reduce((state, instance) => _.set(state, instance.path, instance.value), {} as T);
}

function createFieldExtractor(removeOptionals: boolean) {
function createFieldExtractor(includeOptionals: IncludeOptionals) {
return (context: Context) => {
const instances = context.getData({ requiredOnly: removeOptionals });
const instances = context.getData({ includeOptionals });
return instances.map((instance): FieldInstanceBag => ({ instance, context }));
};
}
Expand Down