Skip to content

Commit

Permalink
feat(core): add partial support for ES6 computed property names (#293)
Browse files Browse the repository at this point in the history
* test(core): add empty string and trailing dot paths cases

* feat(core): add a partial support for computed property names

Computed property names were introduced in ES6.

"Partial" in a sense that few computed property name usage are not
supported. As well for when using with ES5 functions, not arrow
functions.

Closes #289

* docs: adjust odd-property name limitations

* feat(core): improve `getMembersFromArrowFunctionExpr` to avoid map and match all the away

* feat(core): add suport to computed property names for ES5

* docs: remove the ES5 limitation of odd-property name

* feat(core): reset the state of the shared regex before

Since we are using a constant regex that has the `g` flag in iterator
we should make sure that it is in the initial state.
See http://speakingjs.com/es5/ch19.html
  • Loading branch information
micalevisk authored May 18, 2021
1 parent 34fdc33 commit 93d3f66
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 49 deletions.
25 changes: 24 additions & 1 deletion docs-site/docs/misc/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,32 @@ When dealing with Date Time, we should utilize Custom Configuration instead of r

```ts
const selector = (destination) => destination.foo.bar;
// or even
const selector = (d: IFoo) => d['foo'].bar;

// ES5 version works as well
function selector(destination) {
return destination.foo.bar;
}
// with computed property names also works
function selector(destination) {
return destination['foo'].bar;
}
```

This `selector` will be parsed at some point to extract `foo.bar` as property path. One limitation is that if your object contains property name like: `kebab-case-property` or `dotted.property`, the parser won't be able to parse the property path.
This `selector` will be parsed at some point to extract `foo.bar` as property path.

But, for the following cases, the parser won't be able to parse to get the right property path:

```ts
const selector = (d) => d[' a ']; // You could use getters to circumvent this one

// Real computed names
const selector = (d) => d['fo' + 'o'];
const selector = (d) => d[`foo`]; // you cannot use template strings!
const selector = (d) => d[`${'foo'}`];
// and so on...
```

### Property name with number or special characters

Expand Down
121 changes: 91 additions & 30 deletions packages/core/src/lib/create-mapper/get-member-path.util.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,103 @@
import type { Selector } from '@automapper/types';

// TODO: support odd properties like: 'odd-property', 'odd.property'?
/**
* 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;

/**
* For a given cleaned and serialzed JS function selector expression, 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)));

return members;
}

/**
* Get a dot-separated string of the properties selected by a given `fn` selector
* function.
*
* @example
* ```js
* getMemberPath(s => s.foo.bar) === 'foo.bar'
* getMemberPath(s => s['foo']) === 'foo'
* getMemberPath(s => s.foo['']) === 'foo.'
* // invalid usage
* getMemberPath(s => s) === ''
* ```
*/
export function getMemberPath(fn: Selector): string {
const fnString = fn
.toString()
// .replace(/\/\* istanbul ignore next \*\//g, '')
.replace(/cov_.+\n/g, '');

// ES6 prop selector:
// "x => x.prop"
if (fnString.includes('=>')) {
const cleaned = cleanseAssertionOperators(
fnString.substring(fnString.indexOf('.') + 1)
);

if (cleaned.includes('=>')) return '';
return cleaned;
}

// ES5 prop selector:
// "function (x) { return x.prop; }"
// webpack production build excludes the spaces and optional trailing semicolon:
// "function(x){return x.prop}"
// FYI - during local dev testing i observed carriage returns after the curly brackets as well
// Note by maintainer: See https://github.com/IRCraziestTaxi/ts-simple-nameof/pull/13#issuecomment-567171802 for
// explanation of this regex.
const matchRegex = /function\s*\(\w+\)\s*{[\r\n\s]*return\s+\w+\.((\w+\.)*(\w+))/i;

const es5Match = fnString.match(matchRegex);

if (es5Match) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return es5Match[1]!;
}

return '';
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);

return members ? members.join('.') : '';
}

/**
* @returns {string} The given `parseName` but without curly brackets, blank
* spaces, semicolons, parentheses, "?" and "!" characters.
*/
function cleanseAssertionOperators(parsedName: string): string {
return parsedName.replace(/[?!]/g, '').replace(/(?:\s|;|{|}|\(|\)|)+/gm, '');
return parsedName.replace(/[\s{}()?!;]+/gm, '');
}
Loading

0 comments on commit 93d3f66

Please sign in to comment.