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: augments metadata gathering for @wire decorator #631

Merged
14 commits merged into from Sep 14, 2018
Merged

feat: augments metadata gathering for @wire decorator #631

14 commits merged into from Sep 14, 2018

Conversation

ghost
Copy link

@ghost ghost commented Sep 11, 2018

Details

  • If a parameter of a @wire decorator contains an imported reference, this adds support for tracking of such metadata.
    For example @wire(getRecord, { recordId: id }), where userId
    is imported: import id from '@salesforce/user/id';

  • static 1st order references to a literal constant are now resolved, e.g.

    const foo = '123';
    @wire(getRecord, { parameter: foo });
  • In addition to strings, adds support for booleans and numbers

  • If a parameter is unresolved, adds it to metadata with type 'unresolved'

  • Adds type to resolved references/expressions

  • If an array contains an unresolved identifier/expression, marks the array as unresolved

  • Updates to test suite and various bug fixes

Does this PR introduce a breaking change?

  • Yes
  • No

Iurie added 2 commits September 11, 2018 13:11
For example `@wire(getRecord, recordId=userId)`, where recordIs
is imported: `import userId from '@salesforce/user/id'`;
Copy link
Collaborator

@apapko apapko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor changes to handle missing imports

import { wire } from 'lwc';
import { getRecord } from 'recordDataService';
import userId from '@salesforce/user/Id';
export default class Test {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should test relative import

import rel from './relative';
import rel2 from './other/relative2.js';

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love to have other tests a well:

const userId = '00xxxxxxxxxxxxxx';
export default class Test {
	@wire(getRecord, { recordId: userId })
	recordData;
}
const data = {
	userId: '00xxxxxxxxxxxxxx'
}

export default class Test {
	@wire(getRecord, { recordId: data.userId })
	recordData;
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@apapko done
@pmdartus done


if (config) {
wiredValue.static = getWiredStatic(config);
wiredValue.params = getWiredParams(t, config);
}

const getReferenceByName = name => path.scope.getBinding(name).path.parentPath.node.source.value;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should add existence check to the output of getBinding(name) , as the import may not exist

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path.scope.getBinding is always present, the existence check is not needed.

However the traversal: parentPath.node.source.value look really dangerous, we should be more defensive there. Here are couple of examples where this traversal would throw:
http://astexplorer.net/#/gist/0473ef83527151ddf39e4de17b9f7fab/63c21ccaf6616f56cb93a5a3ce6b3e1e979926ab

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like path.scope.getBinding is null when import is missing, fixed & added test, also added some defensive checks but I'm not sure whether that's needed for module imports since I added a restriction to only resolve module imports, I'll need to revisit this.

if (id.isIdentifier()) {
wiredValue.adapter = {
name: id.node.name,
reference: path.scope.getBinding(id.node.name).path.parentPath.node.source.value
reference: getReferenceByName(id.node.name)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could preserve id.node.name reference since its accessed twice here.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

properties.forEach(s => {
if (s.key.type === 'Identifier' && s.value.type === 'ArrayExpression') {
ret[s.key.name] = s.value.elements.map(e => e.value);
} else if (s.key.type === 'Identifier' && s.value.type === 'Identifier') {
ret[s.key.name] = {reference: getReferenceByName(s.value.name)};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we still assign the value if getReferenceByName returned undefined? (in the event of a missing import)

Copy link
Author

@ghost ghost Sep 11, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as per conversation, return undefined explicitly, let me know if I misunderstood the conclusion, I can - not set it otherwise


if (config) {
wiredValue.static = getWiredStatic(config);
wiredValue.params = getWiredParams(t, config);
}

const getReferenceByName = name => path.scope.getBinding(name).path.parentPath.node.source.value;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path.scope.getBinding is always present, the existence check is not needed.

However the traversal: parentPath.node.source.value look really dangerous, we should be more defensive there. Here are couple of examples where this traversal would throw:
http://astexplorer.net/#/gist/0473ef83527151ddf39e4de17b9f7fab/63c21ccaf6616f56cb93a5a3ce6b3e1e979926ab

import { wire } from 'lwc';
import { getRecord } from 'recordDataService';
import userId from '@salesforce/user/Id';
export default class Test {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love to have other tests a well:

const userId = '00xxxxxxxxxxxxxx';
export default class Test {
	@wire(getRecord, { recordId: userId })
	recordData;
}
const data = {
	userId: '00xxxxxxxxxxxxxx'
}

export default class Test {
	@wire(getRecord, { recordId: data.userId })
	recordData;
}

@caridy
Copy link
Contributor

caridy commented Sep 11, 2018

I'm fine with this concept, the only thing to keep clear is that this work for global value providers, and should not be considered for any contextual information since such information can't be imported.

@salesforce-best-lwc-internal
Copy link

Benchmark results

Base commit: 5634840 | Target commit: d055683

@salesforce-best-lwc-internal
Copy link

Benchmark results

Base commit: 5634840 | Target commit: 4e0abbf

@salesforce-best-lwc-internal
Copy link

Benchmark results

Base commit: 5634840 | Target commit: 875004e

Copy link
Contributor

@kevinv11n kevinv11n left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned with how the wire service will evaluate { reference: '@salesforce/user/Id' } when it's evaluating the config object to pass to the wire adapter. I need to debug through this with you to ensure all the dots align.

Edit:
I just realized this is for compiler metadata, not the component output itself, right? If so then my concern is greatly reduced and remains only on the comments I've left elsewhere.

`
import { wire } from 'lwc';
import { getRecord } from 'recordDataService';
import { id as currentUserId } from '@salesforce/user/Id';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is aliasing a named export. All the @salesforce scoped imports use only default exports to handle referential integrity.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kevinv11n do you proposed this reference to be collected as 'undefined' or not collected at all?

},
);
pluginTest(
'sets reference to undefined for const, let references',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why undefined for const with a static value?

Copy link
Author

@ghost ghost Sep 12, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the use case was deemed as unsupported for now (to my understanding)

Copy link
Collaborator

@apapko apapko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Few minor changes.
  2. We briefly spoke about types today when attempting to resolve a complex objects and I wanted to bring up a discussion about the shape of the property value we return. The existing shape of the collected values varies depending on the type of the value we are resolving. For example, we will either return a string or an object depending on the property type. If it is a static 'const' we return a string, however, if it is a module import, we return an object with a reference key. ex:
static: { recordId: { reference: '@salesforce/user/Id' } }

or

static: { recordId: '$recordId' }

I propose to normalize the shape of the value and always return a type and a value. This will allow us to add more details about what exactly has or has not been resolved. For example:

static string:

static: { recordId: {
    type: 'string',
    value: '005000000000000000'
} }

import from module:

static: { recordId: {
    type: 'module',
    value: '@salesforce/userId/id'
} }

import from relative path:

static: { recordId: {
    type: module,
    value: undefined
} }

reference to an object:

params: { recordId: {
    type: 'object',
    value: undefined
} }

let reference

static: { recordId: {
    type: undefined,
    value: undfeined
} }

We could go even further and add something that @pmdartus has proposed, adding 'unresolved' type. This special type can be used to differentiate between undefined values and a failure to resolve a value or a module.

This is discussion is up for debate, but i wanted to get your thoughts on the suggestion.

adapter: { name: 'getRecord', reference: 'recordDataService' },
name: 'recordData',
params: {},
static: {},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we include the 'dotParameter' and set its value as undefined?

Copy link
Author

@ghost ghost Sep 12, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's what I was asking yesterday, I can add that support but it means adding extra explicit checks for a new value type. I'm ok with it either way, if it's not immediately needed we can always add it later, it's not hard either way.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i say if we decided to spend more time on this PR ( enhancing the value shape ), then it may be worth doing this case as well, otherwise let's move on.

`
import { wire } from 'lwc';
import { getRecord } from 'recordDataService';
const userId = '005000000000000000';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we can support static const? the value of 'let' can be changed, therefore we disallow its use, but the 'const' prevents one from doing that. Although, according to the quip doc, we do not resolve any complex types, therefore the 'const' support can only be done for strings.

Copy link
Author

@ghost ghost Sep 12, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be clear you want us to:

  1. support
const foo = '123';
@wire(.. { foo: foo })
  1. NOT support
const foo = '123;
const bar = foo;
@wire(... {bar: bar }) 

Should I do that as part of this story or another ? (the original story was about module imports, but I can tag it to this one).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, there is a way to walk that up to the original binding, but I'm find with supporting number 1 anyways.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right @sfdciuie , It's probably an overkill to keep walking up to resolve the value. We could attempt to do a first level resolution, but not sure if it will worth the effort right now. We can just leave it off as you originally had it then.

@ghost
Copy link
Author

ghost commented Sep 12, 2018

@kevinv11n yes, this is just the metadata.

wireParameters: ['recordId: data.userId'],
expectedStaticParameters: { recordId: { type: 'object' } } });

wireMetadataParameterTest('when an inline initialisation is used, should use value',
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oddly enough, when I added this test it failed, so the metadata wasn't generated for it, had to add code to support it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we use american spelling so it should be initialization

Copy link
Contributor

@kevinv11n kevinv11n left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there support for types other than module, string

{ wireParameters: ['recordId: id'],
expectedStaticParameters: { recordId: {} } });

wireMetadataParameterTest('when importing with "as" from a module should reference the name of the module',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is testing as with a named export. can you add a test with as on a default export?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import foo as bar from .. is afaik invalid syntax (since foo is already an alias) ? I just tried and indeed it's flagged as syntax error.
just in case I added a few more cases, so we are testing:

import foo from
import {foo as bar} from
import { foo } from

wireParameters: ['recordId: data.userId'],
expectedStaticParameters: { recordId: { type: 'object' } } });

wireMetadataParameterTest('when an inline initialisation is used, should use value',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we use american spelling so it should be initialization

wireMetadataParameterTest('when inline array with a non-string literal is used, should have "undefined" for non-string literal values',
{ declaration: `const bar = '123';`,
wireParameters: ['fields: ["foo", bar]'],
expectedStaticParameters: { fields: {type: 'array', value: ['foo', undefined]} } });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder if it's good to do this or treat the whole thing as unresolvable

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was pondering about that, it seems somewhat useful as you'd at least be able to analyze metadata and then figure out that say due to just one parameter an expensive query wasn't pre-cached so action may be taken ?

Up to you though, if you feel that the whole thing should be undefined I can change that.

result = {type: 'object', value: undefined};
}
} else {
result = {type: 'unknown', value: `${s.key.type}-${valueType}`};
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@apapko @kevinv11n @pmdartus Let me know how you feel about this one, added a new type "unknown" with value containing the key type & value type so that if something pops up we know whether there was a gap or something that we don't plan to support.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you give examples where we would return unkown? If it's to guard against cases that we haven't anticipated I would rather fail hard.

if (s.key.type === 'Identifier') {
if (valueType === 'ArrayExpression') {
// @wire(getRecord, { fields: ['Id', 'Name'] })
result = {type: 'array', value: s.value.elements.map(e => e.value)};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we make sure somewhere else the code that array literal items only contains string literals? If it's the case let's add a comment otherwise, we need to be more defensive when accessing the value property.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see my comment regarding these on the appropriate test, we don't do any extra checks, the references get resolved as "undefined", up to you guys whether you want it to work like this or you want to undefine the whole thing, there are pros and cons with each approach.

// const userId = '123';
// @wire(getRecord, { userId: userId })
const reference = getReferenceByName(s.value.name);
result = {value: reference.value, type: reference.type};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's hardcode identifier in type, the reference.type is equal to Identifier (with a capital I) and will not be consistent with other types.

Copy link
Author

@ghost ghost Sep 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I understand? the reference.type has nothing to do with babel we set it ourselves in getReferenceByName, it's either going to be 'module' or 'string'/'number'/'boolean' (or undefined)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh my bad.

result = {value: reference.value, type: reference.type};
} else if (valueType === 'MemberExpression') {
// @wire(getRecord, { userId: recordData.Id })
result = {type: 'object', value: undefined};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather set the value to null instead of undefined.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the thinking behind this was: If we could resolve the value and we know what it is, then we present that value, if we can't resolve it then it's undefined. Setting it to null might erroneously flag the consuming team into thinking we resolved the expression and it resolved to null
eg.

const data = { foo: null; }
@wire (getRecord, { extraParameter: data.foo })

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

possibly the better solution here to resolve ambiguities such as that, is to instead make everything that we're not resolving type: 'unresolved' with value being essentially the type (as a hint?)
so it could look like for a member expression:
{type: 'unresolved', value: 'member_expression'}
for a reference (ie. Identifier/Identifier)
{type: 'unresolved', value: 'reference'}
the only downside is that value has a special meaning here, which is not symmetrical to the resolved cases
@apapko thoughts ?

result = {type: 'object', value: undefined};
}
} else {
result = {type: 'unknown', value: `${s.key.type}-${valueType}`};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you give examples where we would return unkown? If it's to guard against cases that we haven't anticipated I would rather fail hard.


if (config) {
wiredValue.static = getWiredStatic(config);
wiredValue.params = getWiredParams(t, config);
}

const getReferenceByName = getScopedReferenceByName.bind(this, path.scope);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bind usage is not intuitive. I would rather use currying:

const referenceLookup = scope => name => {
    // logic
}

Copy link
Author

@ghost ghost Sep 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just going by the existing convention in this file, where everythin was function() {}, I can change that of course,

do you mean

const getScopedReferenceByName = scope => name => {
    const binding = scope.getBinding(name);

on the outside, and then

        const getReferenceByName = getScopedReferenceByName(path.scope);

inside the module.export ?

@@ -94,9 +127,39 @@ function getWiredParamMetadata(properties) {
return ret;
}

function getScopedReferenceByName(scope, name) {
let binding = scope.getBinding(name);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make binding const

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Collaborator

@apapko apapko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for adding such great test coverage!

@ghost
Copy link
Author

ghost commented Sep 14, 2018

@apapko @kevinv11n @pmdartus Added a final change to mark arrays with unsupported literals as unresolved, and 2 more tests for the cases where one element is unresolved and for the case where all elements are resolved.

@ghost ghost changed the title feat: adds metadata support for imported properties in @wire decorator feat: augments metadata support gathering for @wire decorator Sep 14, 2018
@ghost ghost changed the title feat: augments metadata support gathering for @wire decorator feat: augments metadata gathering for @wire decorator Sep 14, 2018
@ghost ghost merged commit 4920a4e into salesforce:master Sep 14, 2018
@ghost ghost deleted the static-property-reference branch September 14, 2018 18:49
This pull request was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants