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

fix(cfn-include): fails to load SAM resources #9442

Merged
merged 3 commits into from
Aug 6, 2020

Conversation

skinny85
Copy link
Contributor

@skinny85 skinny85 commented Aug 5, 2020

We incorrectly handled union-types in the fromCloudFormation() generated code.
Also we merged the 'Transform' sections of the CloudFormation template incorrectly,
which has also been fixed in this change.


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license

@skinny85 skinny85 requested a review from rix0rrr August 5, 2020 01:02
@skinny85 skinny85 self-assigned this Aug 5, 2020
@mergify mergify bot added the contribution/core This is a PR that came from AWS. label Aug 5, 2020
@rix0rrr rix0rrr changed the title fix(cfn-include): correctly handle SAM resources fix(cfn-include): fails to load SAM resources Aug 5, 2020
@rix0rrr rix0rrr added the pr/do-not-merge This PR should not be merged at this time. label Aug 5, 2020
Copy link
Contributor

@rix0rrr rix0rrr left a comment

Choose a reason for hiding this comment

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

Man this code is complicated. Both the task it's trying to achieve, but also in the implementation.

I trust that it does what it needs to do. Got some notes on potential cleanup strategies, do with those as you will.

const array1 = val1 == null ? [] : (Array.isArray(val1) ? val1 : [val1]);
const array2 = val2 == null ? [] : (Array.isArray(val2) ? val2 : [val2]);
for (const value of array2) {
if (!array1.includes(value)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Accidentally quadratic here. Not sure the set ever grows big enough for it to be an issue, but hope you thought about this and weighed the risk intentionally.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. I don't think this will ever realistically contain more than 2 elements.

public visitAtomUnion(types: genspec.CodeName[]): string {
const validatorNames = types.map(type => genspec.validatorName(type).fqn).join(', ');

const arg = `prop${this.depth}`;
Copy link
Contributor

Choose a reason for hiding this comment

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

If you made a function that returns unique variable names (and honestly, it could be global, why not?) you could reduce on the complexity of this.deeperCopy() and below the 2 deeperCopy()s.

In this generated code, there's no requirement that we use the least amount of variable names possible.

Just put this somewhere in this file and use it:

const freshVariable = (() => {
  let ctr = 1;
  return () => `var${ctr++}`;
}());

If you really wanted to be fancy you could make it scoped and reduce the counter again after the block to really end up with minimal variable names:

let ctr = 0;
function withFreshVariable<A>(block: (var: string) => A) {
  ctr += 1;
  const ret = block(`var${ctr}`);
  ctr -= 1;
  return ret;
}

// To be used as:
return withFreshVariable(var1 => withFreshVariable(var2 => `const ${var2} = ${var1}`));

// Or even:
function renderLambda(block: (var: string) => string) {
  return withFreshVariable((var) => `(${var}: any) => ${block(var)}`);
}

// To be used as:
return renderLambda(v => `${FromCfnVisitor.fromExpression(v).visitAtom(itemType)}`);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tried your withFreshVariable refactoring, but it doesn't really do what you want in this case:

        const arg1 = `prop${this.depth}`;
        const arg2 = `prop${this.depth + 1}`;
        const mappers = itemTypes
          // we need a copy 2 levels deep, as we're already using prop2 in arg2 here
          .map(type => `(${arg2}: any) => ${this.deeperCopy(arg1).deeperCopy(arg2).visitAtom(type)}`)
          .join(', ');

        return `${CFN_PARSE}.FromCloudFormation.getArray(${this.baseExpression}, ` +
          `(${arg1}: any) => ${CFN_PARSE}.FromCloudFormation.getTypeUnion([${validatorNames}], [${mappers}], ${arg1})` +
          ')';

withFreshVariable does nothing for us here (they would have to be nested calls).

I did a small refactoring of the logic though, and I think it's much more clear now. Let me know how you like it compared to the old one.

Copy link
Contributor

Choose a reason for hiding this comment

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

withFreshVariable does nothing for us here (they would have to be nested calls).

Yes, they have to be nested calls. That's why it takes a block and returns the block's return value. Sorry if that wasn't clear.

const arg2 = `prop${this.depth + 1}`;
const mappers = itemTypes
// we need a copy 2 levels deep, as we're already using prop2 in arg2 here
.map(type => `(${arg2}: any) => ${this.deeperCopy(arg1).deeperCopy(arg2).visitAtom(type)}`)
Copy link
Contributor

Choose a reason for hiding this comment

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

I had to spent a solid 20 minutes perusing the code to see why you needed 2 copies here. Let's not? :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let me know if it's clearer why that's needed in this new version.

@@ -117,6 +118,18 @@ export class FromCloudFormation {
value: tag.Value,
};
}

public static getTypeUnion(validators: Validator[], mappers: Mapper[], value: any): any {
Copy link
Contributor

Choose a reason for hiding this comment

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

Feels like this function would be better off taking an array of pairs, rather than a pair of arrays?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I modeled this after the existing unionMapper function in runtime.ts.

Making it an array of pairs would make it much more difficult to write code like this:

        const validatorNames = types.map(type => genspec.validatorName(type).fqn).join(', ');

        const arg = `prop${this.depth}`;
        const mappers = types
          .map(type => `(${arg}: any) => ${this.deeperCopy(arg).visitAtom(type)}`)
          .join(', ');

        return `${CFN_PARSE}.FromCloudFormation.getTypeUnion([${validatorNames}], [${mappers}], ${this.baseExpression})`;

With the current modeling, you can easily generate separate validators and mappers. Making getTypeUnion () take an array of pairs would make this a fair bit more complicated.

const scalarValidator = `${CORE}.unionValidator(${scalarValidatorNames})`;
const listValidator = `${CORE}.listValidator(${CORE}.unionValidator(${itemValidatorNames}))`;

return `${CFN_PARSE}.FromCloudFormation.getTypeUnion([${scalarValidator}, ${listValidator}], [${scalarMapper}, ${listMapper}], ${this.baseExpression})`;
}

private deeperCopy(baseExpression: string): FromCloudFormationFactoryVisitor {
Copy link
Contributor

Choose a reason for hiding this comment

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

Once we get rid of the depth, this doesn't really need any instance variables anymore, and the "copy" word implies a lot of statekeeping that is making me uncomfortable. Wouldn't factory functions make more sense?

FromCloudFormationFactoryVisitor.fromExpression(...)
// ^--- this is a little long then, might want to rename it

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can't get rid of depth because of #9442 (comment)

@rix0rrr
Copy link
Contributor

rix0rrr commented Aug 5, 2020

Hey something just popped into my head -- if the thing the mappers return are themselves functions (which I think they are), then we don't need to render any lambdas, do we?

Because:

(x: any) => doAThing(x)

// The same as
doAThing

// (modulo "this" binding because... you know... JavaScript)

Might simplify the code some more. Do we really really need the lambdas?

@skinny85
Copy link
Contributor Author

skinny85 commented Aug 5, 2020

Hey something just popped into my head -- if the thing the mappers return are themselves functions (which I think they are), then we don't need to render any lambdas, do we?

Because:

(x: any) => doAThing(x)

// The same as
doAThing

// (modulo "this" binding because... you know... JavaScript)

Might simplify the code some more. Do we really really need the lambdas?

We can't really render functions in the visit* methods (otherwise, that's how I would do it from the start).

Let's take CloudFront's CfnDistributionProps. They take a required complex object as one of its properties, DistributionConfigProperty. So the function for them has to look like this:

function CfnDistributionPropsFromCloudFormation(properties: any): CfnDistributionProps {
    properties = properties || {};
    return {
        distributionConfig: DistributionConfigPropertyFromCloudFormation(properties.DistributionConfig),
        // ...
    };
}

where DistributionConfigPropertyFromCloudFormation has to return DistributionConfigProperty in order for the code to compile.

However, if there is a different property that takes an optional DistributionConfigProperty, the code has to look like this:

function SomePropsFromCloudFormation(properties: any): SomeProps {
    properties = properties || {};
    return {
        distributionConfig: properties.DistributionConfig == null ? undefined : DistributionConfigPropertyFromCloudFormation(properties.DistributionConfig),
        // ...
    };
}

So visitAtom has to return either DistributionConfigPropertyFromCloudFormation(properties.DistributionConfig), or properties.DistributionConfig == null ? undefined : DistributionConfigPropertyFromCloudFormation(properties.DistributionConfig). It cannot just return DistributionConfigPropertyFromCloudFormation (the function itself).

We incorrectly handled union-types in the fromCloudFormation() generated code.
Also we merged the 'Transform' sections of the CloudFormation template incorrectly,
which has also been fixed in this change.
@skinny85 skinny85 force-pushed the fix/cfn-include-sam-transform branch from c74e4e3 to 037011d Compare August 5, 2020 21:59
@skinny85 skinny85 requested a review from rix0rrr August 5, 2020 21:59
@rix0rrr
Copy link
Contributor

rix0rrr commented Aug 6, 2020

So visitAtom has to return either DistributionConfigPropertyFromCloudFormation(properties.DistributionConfig), or properties.DistributionConfig == null ? undefined : DistributionConfigPropertyFromCloudFormation(properties.DistributionConfig). It cannot just return DistributionConfigPropertyFromCloudFormation (the function itself).

Ah, but using higher order functions we could:

function fromOptional(innerMapper: (x: any) => any) {
  return (x: any) => x != null ? innerMapper(x) : undefined;
}

function SomePropsFromCloudFormation(properties: any): SomeProps {
    properties = properties || {};
    return {
        distributionConfig: fromOptional(DistributionConfigPropertyFromCloudFormation)(properties.DistributionConfig),
        // ...
    };
}

I'm not saying there has to be exactly one function that we return the name of. I'm saying we return an expression that evaluates to a function. In this case: fromOptional(DistributionConfigPropertyFromCloudFormation).


const arg = `prop${this.depth}`;
// we need a deeper copy here, as 'mappers' is used in a lambda expression below
const deeperVisitor = this.deeperCopy();
Copy link
Contributor

Choose a reason for hiding this comment

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

Again, this has the goal of JUST increasing depth, which is not super clear to me.

Two big changes:

* Make the parsing functions "lazy". They don't need to deserialize a
  value right then and there. Instead, they return a function that will
  do the promised deserialization. This removes the need for Lambdas and
  hence for unique variable names.

* Optional-ness is an attribute of a "property", not of a "type". It
  therefore only needs to be considered at the top-level of the
  CfnFactoryFunction, and not in any of the deeper deserializers.

  It is therefore state that can be removed from
  `FromCloudFormationFactoryVisitor`.

Similar for tags (I hope, because I'm not too sure what that code
was trying to achieve).

At the end of this, `FromCloudFormationFactoryVisitor` is now
stateless and could be a singleton (but I left the class in place).
@rix0rrr
Copy link
Contributor

rix0rrr commented Aug 6, 2020

I added a commit showing what I mean, with a description of the change in the commit body.

Having the visitor return a function which is then applied to the actual value is also how the serializer works, so it makes for a nice symmetry, see here:

https://github.com/aws/aws-cdk/blob/master/tools/cfn2ts/lib/codegen.ts#L502

If you don't like it you can revert it (but I hope you wouldn't).

Might have made some mistakes around tags, btw. Although I guess the only reason to have the 'as any' there was to escape the type system, and it seems to work equally well at the top level...

@skinny85
Copy link
Contributor Author

skinny85 commented Aug 6, 2020

I added a commit showing what I mean, with a description of the change in the commit body.

Having the visitor return a function which is then applied to the actual value is also how the serializer works, so it makes for a nice symmetry, see here:

https://github.com/aws/aws-cdk/blob/master/tools/cfn2ts/lib/codegen.ts#L502

If you don't like it you can revert it (but I hope you wouldn't).

Might have made some mistakes around tags, btw. Although I guess the only reason to have the 'as any' there was to escape the type system, and it seems to work equally well at the top level...

Looks nice, thanks! I agree it's simpler.

For tags, all of the tests pass, so I assume it's working 😃.

@skinny85 skinny85 removed the pr/do-not-merge This PR should not be merged at this time. label Aug 6, 2020
@mergify
Copy link
Contributor

mergify bot commented Aug 6, 2020

Thank you for contributing! Your pull request will be updated from master and then merged automatically (do not update manually, and be sure to allow changes to be pushed to your fork).

@aws-cdk-automation
Copy link
Collaborator

AWS CodeBuild CI Report

  • CodeBuild project: AutoBuildProject6AEA49D1-qxepHUsryhcu
  • Commit ID: dcc2899
  • Result: SUCCEEDED
  • Build Logs (available for 30 days)

Powered by github-codebuild-logs, available on the AWS Serverless Application Repository

@mergify
Copy link
Contributor

mergify bot commented Aug 6, 2020

Thank you for contributing! Your pull request will be updated from master and then merged automatically (do not update manually, and be sure to allow changes to be pushed to your fork).

@mergify mergify bot merged commit 1de9dc8 into aws:master Aug 6, 2020
@skinny85 skinny85 deleted the fix/cfn-include-sam-transform branch August 6, 2020 19:00
@skinny85 skinny85 restored the fix/cfn-include-sam-transform branch August 7, 2020 23:26
eladb pushed a commit that referenced this pull request Aug 10, 2020
We incorrectly handled union-types in the fromCloudFormation() generated code.
Also we merged the 'Transform' sections of the CloudFormation template incorrectly,
which has also been fixed in this change.

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
contribution/core This is a PR that came from AWS.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants