Skip to content

Commit

Permalink
Optional chaining: a?.b
Browse files Browse the repository at this point in the history
  • Loading branch information
aajanki committed Oct 26, 2024
1 parent 1d4bcb0 commit 1c43c89
Show file tree
Hide file tree
Showing 3 changed files with 281 additions and 37 deletions.
88 changes: 52 additions & 36 deletions language_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ It is not possible to construct a bytes object expect by calling a function that

In addition to the literal `null`, the Typescript `undefined` value is also treated as `null` in Workflows YAML.

### Implicit type conversions

Expressions that combine variables with operators such as `+`, `>`, `==` perform implict type conversions according to the [rules listed on GCP Workflows documentation](https://cloud.google.com/workflows/docs/reference/syntax/datatypes#implicit-conversions). For example, applying `+` to a string and a number concatenates the values into a string.

⚠️ Checking if a variable is null or not must be done by an explicit comparison: `if (var != null) {...}`. Relying in an implicit conversion, such as `if (var) {...}`, results in a TypeError at runtime.

## Expressions

Most Typescript expressions work as expected.
Expand All @@ -56,7 +62,7 @@ name === 'Bean'
sys.get_env('GOOGLE_CLOUD_PROJECT_ID')
```

Operators:
## Operators

| Operator | Description |
| ------------ | -------------------------------------------- |
Expand All @@ -71,16 +77,58 @@ Operators:
| &&, \|\|, ! | logical operators |
| in | check if a property is present in an object |
| ?? | nullish coalescing |
| ?. | optional chaining |
| ? : | conditional operator |

The [precendence order of operators](https://cloud.google.com/workflows/docs/reference/syntax/datatypes#order-operations) is the same as in GCP Workflows.

See [expression in GCP Workflows](https://cloud.google.com/workflows/docs/reference/syntax/expressions) for more information.

### Implicit type conversions
### Conditional (ternary) operator

Expressions that combine variables with operators such as `+`, `>`, `==` perform implict type conversions according to the [rules listed on GCP Workflows documentation](https://cloud.google.com/workflows/docs/reference/syntax/datatypes#implicit-conversions). For example, applying `+` to a string and a number concatenates the values into a string.
The expression

```javascript
x > 0 ? 'positive' : 'not positive'
```

is converted to an [if() expression](https://cloud.google.com/workflows/docs/reference/stdlib/expression-helpers#conditional_functions):

```yaml
${if(x > 0, "positive", "not positive")}
```

⚠️ Checking if a variable is null or not must be done by an explicit comparison: `if (var != null) {...}`. Attempting to rely in implicit conversion, such as `if (var) {...}`, results in a TypeError at runtime.
⚠️ Note that Workflows always evaluates both expression branches unlike Typescript which evaluates only the branch that gets executed.

### Nullish coalescing operator

The expression

```javascript
x ?? 'default value'
```
is converted to a [default() expression](https://cloud.google.com/workflows/docs/reference/stdlib/expression-helpers#conditional_functions):
```yaml
${default(x, "default value")}
```
⚠️ Note that Workflows always evaluates the right-hand side expression unlike Typescript which evaluates the right-hand side only if the left-hand side is `null` or `undefined`.
### Optional chaining
The optional chaining expression
```javascript
data.user?.name
```
is converted to a [map.get() expression](https://cloud.google.com/workflows/docs/reference/stdlib/map/get):
```yaml
${map.get(data, ["user", "name"])}
```
## Template literals
Expand Down Expand Up @@ -313,38 +361,6 @@ steps:
return: ${b}
```

## Conditional (ternary) operator

The expression

```javascript
x > 0 ? 'positive' : 'not positive'
```

is converted to an [if() expression](https://cloud.google.com/workflows/docs/reference/stdlib/expression-helpers#conditional_functions):

```yaml
${if(x > 0, "positive", "not positive")}
```

⚠️ Note that Workflows always evaluates both expression branches unlike Typescript which evaluates only the branch that gets executed.

## Nullish coalescing operator

The expression

```javascript
x ?? 'default value'
```

is converted to an [default() expression](https://cloud.google.com/workflows/docs/reference/stdlib/expression-helpers#conditional_functions):

```yaml
${default(x, "default value")}
```

⚠️ Note that Workflows always evaluates the right-hand side expression unlike Typescript which evaluates the right-hand side only if the left-hand side is `null` or `undefined`.

## Loops

The fragment
Expand Down
126 changes: 125 additions & 1 deletion src/transpiler/expressions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */
import { AST_NODE_TYPES } from '@typescript-eslint/utils'
import { TSESTree } from '@typescript-eslint/typescript-estree'
import {
BinaryExpression,
BinaryOperator,
Expand All @@ -13,13 +14,14 @@ import {
isExpression,
isFullyQualifiedName,
} from '../ast/expressions.js'
import { WorkflowSyntaxError } from '../errors.js'
import { InternalTranspilingError, WorkflowSyntaxError } from '../errors.js'
import { assertOneOfManyTypes, assertType } from './asserts.js'

const {
ArrayExpression,
AwaitExpression,
CallExpression,
ChainExpression,
ConditionalExpression,
Identifier,
Literal,
Expand Down Expand Up @@ -124,6 +126,9 @@ function convertExpressionOrPrimitive(instance: any): Primitive | Expression {
case AST_NODE_TYPES.MemberExpression:
return convertMemberExpression(instance)

case ChainExpression:
return convertChainExpression(instance)

case CallExpression:
return convertCallExpression(instance)

Expand Down Expand Up @@ -269,9 +274,128 @@ export function convertMemberExpression(node: any): Expression {
)
}

function convertChainExpression(node: any): Expression {
assertType(node, ChainExpression)

const properties = chainExpressionToFlatArray(node.expression)
const args = optinalChainToMapGetArguments(properties)

return new FunctionInvocationExpression('map.get', args)
}

interface ChainedProperty {
property: TSESTree.BaseNode
optional: boolean
computed: boolean
}

function chainExpressionToFlatArray(node: any): ChainedProperty[] {
if (node.type === AST_NODE_TYPES.MemberExpression) {
const data = {
property: node.property as TSESTree.BaseNode,
optional: node.optional as boolean,
computed: node.computed as boolean,
}

return chainExpressionToFlatArray(node.object).concat([data])
} else {
return [
{
property: node as TSESTree.BaseNode,
optional: false,
computed: false,
},
]
}
}

function optinalChainToMapGetArguments(
properties: ChainedProperty[],
): Expression[] {
if (properties.length <= 0) {
// this shouldn't happen
return []
}

let base: Expression
let optionalSliceStart: number
const firstOptional = properties.findIndex((p) => p.optional)

if (firstOptional > 2) {
const baseProperties = properties.slice(0, firstOptional - 1)
base = memberExpressionFromList(baseProperties)
optionalSliceStart = firstOptional - 1
} else if (firstOptional >= 0) {
base = convertExpression(properties[0].property)
optionalSliceStart = 1
} else {
// firstOptional < 0, this shouldn't happen
base = memberExpressionFromList(properties)
optionalSliceStart = properties.length
}

const optionals = properties.slice(optionalSliceStart).map((opt) => {
const propertyExp = convertExpression(opt.property)
if (opt.computed) {
return propertyExp
} else if (isFullyQualifiedName(propertyExp)) {
return new PrimitiveExpression(propertyExp.toString())
} else {
throw new WorkflowSyntaxError(
'Unexpected property in an optional chain',
opt.property.loc,
)
}
})

const args = [base]
if (optionals.length > 1) {
args.push(new PrimitiveExpression(optionals))
} else if (optionals.length === 1) {
args.push(optionals[0])
}

return args
}

function memberExpressionFromList(properties: ChainedProperty[]): Expression {
if (properties.length >= 2) {
const base = new MemberExpression(
convertExpression(properties[0].property),
convertExpression(properties[1].property),
properties[1].computed,
)

return properties
.slice(2)
.reduce(
(exp, current) =>
new MemberExpression(
exp,
convertExpression(current.property),
current.computed,
),
base,
)
} else if (properties.length === 1) {
return convertExpression(properties[0].property)
} else {
throw new InternalTranspilingError(
'Empty array in memberExpressionFromList()',
)
}
}

function convertCallExpression(node: any): Expression {
assertType(node, CallExpression)

if (node.optional) {
throw new WorkflowSyntaxError(
'Optional call expressions are not supported',
node.loc,
)
}

const calleeExpression = convertExpression(node.callee)
if (isFullyQualifiedName(calleeExpression)) {
const nodeArguments = node.arguments as any[]
Expand Down
104 changes: 104 additions & 0 deletions test/expressions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,110 @@ describe('Expressions', () => {
assertExpression('error as {code: number}', '${error}')
assertExpression('(error as {code: number}).number', '${error.number}')
})

it('transpiles optional chaining as map.get()', () => {
const ast = transpile(`function test(data) {
return data?.name;
}`)
const expected = `
test:
params:
- data
steps:
- return1:
return: \${map.get(data, "name")}
`

expect(YAML.parse(ast)).to.deep.equal(YAML.parse(expected))
})

it('transpiles nested optional chains', () => {
const ast = transpile(`function test(data) {
return data.person[3].address?.city?.id;
}`)
const expected = `
test:
params:
- data
steps:
- return1:
return: \${map.get(data.person[3], ["address", "city", "id"])}
`

expect(YAML.parse(ast)).to.deep.equal(YAML.parse(expected))
})

it('transpiles optional chains with alternativing optional and non-optional elements', () => {
const ast = transpile(`function test(data) {
return data.person?.address.city?.id;
}`)
const expected = `
test:
params:
- data
steps:
- return1:
return: \${map.get(data, ["person", "address", "city", "id"])}
`

expect(YAML.parse(ast)).to.deep.equal(YAML.parse(expected))
})

it('transpiles (data?.a).b', () => {
const ast = transpile(`function test(data) {
return (data?.a).b;
}`)
const expected = `
test:
params:
- data
steps:
- return1:
return: \${map.get(data, "a").b}
`

expect(YAML.parse(ast)).to.deep.equal(YAML.parse(expected))
})

it('transpiles (data?.a)?.b', () => {
const ast = transpile(`function test(data) {
return (data?.a)?.b;
}`)
const expected = `
test:
params:
- data
steps:
- return1:
return: \${map.get(map.get(data, "a"), "b")}
`

expect(YAML.parse(ast)).to.deep.equal(YAML.parse(expected))
})

it('transpiles optional chaining with bracket notation', () => {
const ast = transpile(`function test(data) {
return data?.["na" + "me"];
}`)
const expected = `
test:
params:
- data
steps:
- return1:
return: \${map.get(data, "na" + "me")}
`

expect(YAML.parse(ast)).to.deep.equal(YAML.parse(expected))
})

it('optional call expression is not supported', () => {
const code = `function test() {
return sys.now?.();
}`

expect(() => transpile(code)).to.throw()
})
})

describe('Variable references', () => {
Expand Down

0 comments on commit 1c43c89

Please sign in to comment.