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(core): use proxy to get members from selector function #301

Merged
merged 2 commits into from
Jun 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,9 @@ export function createMapForMember<
// initialize sourcePath
let sourcePath = '';

// if the transformation is MapFrom or MapWith, we have information on the source value selector
// if the transformation is MapWith, we have information on the source value selector
if (
(mapMemberFn[MapFnClassId.type] === TransformationType.MapFrom ||
mapMemberFn[MapFnClassId.type] === TransformationType.MapWith) &&
(mapMemberFn[MapFnClassId.type] === TransformationType.MapWith) &&
mapMemberFn[MapFnClassId.misc] != null
) {
sourcePath = getMemberPath(mapMemberFn[MapFnClassId.misc]!);
Expand Down
102 changes: 28 additions & 74 deletions packages/core/src/lib/create-mapper/get-member-path.util.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,22 @@
import type { Selector } from '@automapper/types';
import type { Dictionary, Selector } from '@automapper/types';

/**
* An regular expression to match will all property names of a given cleaned
* arrow function expression. Note that if there is some computed names thus
* they are returning if the quotes.
*
* @example
* ```js
* "s=>s.foo['bar']".match(RE_ARROW_FN_SELECTOR_PROPS)
* // will return
* ["foo", "'bar'"]
* ```
*
* ### Explanation:
* ```
* (?: // (begin of non-capturing group)
* (?<= // (begin of positive lookbehind)
* \[ // matches a literal "[" but without including it in the match result
* ) // (end of positive lookbehind)
* ( // (begin capturing group #1)
* ['"] // match one occurrence of a single or double quotes characters
* ) // (end capturing group #1)
* ( // (begin capturing group #2)
* .*? // followed by 0 or more of any character, but match as few characters as possible (which is 0)
* ) // (end capturing group #2)
* \1 // followed by the result of capture group #1
* ) // (end of non-capturing group)
* | // Or matches with...
* (?: // (begin of non-capturing group)
* (?<= // (begin of positive lookbehind)
* \. // matches a literal "." but without including it in the match result
* ) // (end of positive lookbehind)
* [^.[]+ // followed by 1 or more occurrences of any character but "." nor "["
* ) // (end of non-capturing group)
* ```
*/
const RE_FN_SELECTOR_PROPS = /(?:(?<=\[)(['"])(.*?)\1)|(?:(?<=\.)[^.[]+)/g;
const PROXY_TARGET = (): undefined => undefined;
const PROXY_OBJECT = createProxy(PROXY_TARGET);

/**
* For a given cleaned and serialzed JS function selector expression, return a
* list of all members that were selected.
* For a given JS function selector, return a list of all members that were selected.
*
* @returns `null` if the given `fnSelector` doesn't match with anything.
*/
export function getMembers(fnSelectorStr: string): string[] | null {
// Making sure that the shared constant `/g` regex is in its initial state.
RE_FN_SELECTOR_PROPS.lastIndex = 0;

let matches = RE_FN_SELECTOR_PROPS.exec(fnSelectorStr);

if (!matches) return null;

const members: string[] = [];
do {
// Use the value of the second captured group or the entire match, since
// we do not want to capture the matching quotes (when any)
const propFound = matches[2] ?? matches[0];
// ^^ Using the nullish operator since the left
// side could be an empty string, which is falsy.
members.push(propFound);
} while ((matches = RE_FN_SELECTOR_PROPS.exec(fnSelectorStr)));

export function getMembers(fnSelector: Selector<unknown, unknown | (() => string[])>): string[] | null {
const resultProxy = fnSelector(PROXY_OBJECT) as () => string[];
if (typeof resultProxy !== 'function') {
return null;
}
const members: string[] = resultProxy();
if (members.length === 0 || members.some(m => typeof m !== 'string')) {
return null;
}
return members;
}

Expand All @@ -78,26 +34,24 @@ export function getMembers(fnSelectorStr: string): string[] | null {
* ```
*/
export function getMemberPath(fn: Selector): string {
const fnString = fn
.toString()
// .replace(/\/\* istanbul ignore next \*\//g, '')
.replace(/cov_.+\n/g, '');

const cleaned = cleanseAssertionOperators(fnString);

// Note that we don't need to remove the `return` keyword because, for instance,
// `(x) => { return x.prop }` will be turn into `x=>returnx.prop` (cleaned)
// thus we'll still be able to get only the string `prop` properly.
// The same for `function (x){}`
const members = getMembers(cleaned);

const members = getMembers(fn);
return members ? members.join('.') : '';
}

/**
* @returns {string} The given `parseName` but without curly brackets, blank
* spaces, semicolons, parentheses, "?" and "!" characters.
* @returns {Proxy} A proxy that's wrap on the target object and track of
* the path of accessed nested properties
*/
function cleanseAssertionOperators(parsedName: string): string {
return parsedName.replace(/[\s{}()?!;]+/gm, '');
function createProxy<T extends Dictionary<T>>(target: T, path: string[] = []): T {
const realTraps: ProxyHandler<T> = {
get(target: T, p: string): typeof PROXY_TARGET {
const childPath = path.slice();
childPath.push(p);
return createProxy(PROXY_TARGET, childPath);
},
apply(): string[] {
return path;
}
};
return new Proxy(target, realTraps);
}
86 changes: 51 additions & 35 deletions packages/core/src/lib/create-mapper/specs/get-member-path.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getMembers, getMemberPath } from '../get-member-path.util';

describe('getMembers', () => {
describe('cases that are allowed', () => {
const cases = [
const cases: Array<[(obj: any) => any, string[] | null]> = [
[(something) => something.foo, ['foo']],
[(s) => s.foo, ['foo']],
[(s) => s.foo.bar, ['foo', 'bar']],
Expand Down Expand Up @@ -39,10 +39,13 @@ describe('getMembers', () => {
[(s) => s[''], ['']],
[(s) => s.foo[''], ['foo', '']],
[(s) => s[''].foo, ['', 'foo']],
].map<[serializedSelector: string, members: string[]]>(([fn, expected]) => [
fn.toString(),
expected as string[],
]);

[(s) => s['fo' + 'o'], ['foo']], // expected to be ['foo']
// eslint-disable-next-line no-constant-condition
[(s) => s[true ? 'foo' : 'bar'], ['foo']], // expected to be ['foo']
[(s) => s[true && 'foo'], ['foo']], // expected to be ['foo']
[(s) => s[`a`], ['a']] // To discourage the use of computed names
];

test.each(cases)(
'for "%s" should return %p list',
Expand All @@ -54,23 +57,14 @@ describe('getMembers', () => {
});

describe('cases that are disallowed', () => {
const cases = [
const cases: Array<[(obj: any) => any, (string | symbol)[] | null]> = [
[(s) => s, null], // Not a real one tho
[(s) => s`foo`, null],

// Known limitations that should be avoided in user land code because
// they will produce wrong outputs and cannot be detected beforehand
[(s) => s['fo' + 'o'], ['fo']], // expected to be ['foo']
// eslint-disable-next-line no-constant-condition
[(s) => s[true ? 'foo' : 'bar'], null], // expected to be ['foo']
[(s) => s[true && 'foo'], null], // expected to be ['foo']

[(s) => s[`foo`](), null], //user mustn't call the proxy method
[() => null, null], // if null passed
[() => undefined, null], // if undefined passed
[(s) => s[Symbol()], null], // I'm not sure if we should support this
[(s) => s[`a`], null], // To discourage the use of computed names
].map<[serializedSelector: string, members: string[]]>(([fn, expected]) => [
fn.toString(),
expected as string[],
]);
];

test.each(cases)(
'for "%s" should return %p',
Expand Down Expand Up @@ -98,6 +92,16 @@ describe('getMemberPath', () => {
'even[odd].prop': string;
á: string;
with_sṕéçiâl_chàrs: string;
[' foo ']: {
[' bar ']: {
baz: string;
}
}
[' odd-property ']: {
[' odd+property ']: {
baz: string;
}
}
}

it('should return properly for ES6 arrow syntax', () => {
Expand Down Expand Up @@ -131,6 +135,15 @@ describe('getMemberPath', () => {

path = getMemberPath((s: Foo) => s['with_sṕéçiâl_chàrs']);
expect(path).toEqual('with_sṕéçiâl_chàrs');

path = getMemberPath((s: Foo) => s[' foo ']);
expect(path).toEqual(' foo ');

path = getMemberPath((s: Foo) => s['odd' + '-' + 'property']);
expect(path).toEqual('odd-property');

path = getMemberPath((s: Foo) => s[`${'odd-property'}`]);
expect(path).toEqual('odd-property');
});

it('should return properly for nested path for ES6 arrow syntax', () => {
Expand All @@ -144,6 +157,15 @@ describe('getMemberPath', () => {

path = getMemberPath((s: Foo) => s.bar.baz['']);
expect(path).toEqual('bar.baz.');

path = getMemberPath((s: Foo) => s[' foo '][' bar '].baz);
expect(path).toEqual(' foo . bar .baz');

path = getMemberPath((s: Foo) => s['odd' + '-' + 'property']['odd' + '+' + 'property']);
expect(path).toEqual('odd-property.odd+property');

path = getMemberPath((s: Foo) => s[`${'odd-property'}`][`${'odd+property'}`].baz);
expect(path).toEqual('odd-property.odd+property.baz');
});

it('should return properly for ES5 function syntax', () => {
Expand All @@ -163,6 +185,16 @@ describe('getMemberPath', () => {
return s['foo'];
});
expect(path).toEqual('foo');

path = getMemberPath(function (s: Foo) {
return s[' odd' + '-' + 'property '];
});
expect(path).toEqual(' odd-property ');

path = getMemberPath(function (s: Foo) {
return s[`${'odd-property'}`]
});
expect(path).toEqual('odd-property');
});

it('should return properly for nested path for ES5 function syntax', () => {
Expand Down Expand Up @@ -212,20 +244,4 @@ describe('getMemberPath', () => {
});
expect(path).toEqual('returnFoo');
});

describe('For non supported use cases', () => {
it('should return the wrong output', () => {
let path: ReturnType<typeof getMemberPath>;

path = getMemberPath((s: Foo) => s[' foo ']);
expect(path).toEqual('foo');

path = getMemberPath((s: Foo) => s['odd' + '-' + 'property']);
expect(path).toEqual('odd');

// Since template strings are not allowed
path = getMemberPath((s: Foo) => s[`${'odd-property'}`]);
expect(path).toEqual('');
});
});
});