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

Compile time annotation expression language #8323

Closed
GavrilovSV opened this issue Nov 10, 2022 · 7 comments
Closed

Compile time annotation expression language #8323

GavrilovSV opened this issue Nov 10, 2022 · 7 comments
Labels
type: enhancement New feature or request
Milestone

Comments

@GavrilovSV
Copy link
Contributor

GavrilovSV commented Nov 10, 2022

Feature description

@graemerocher
Following the discussion started in #7897, this issue is aimed at working out a vision of annotation expression language similar to SpEL, but with most evaluation stages executed at compilation time.

By default an expression is annotation member value surrounded with #{ }. It would also be useful to introduce special annotation members which values will be treated as expressions without #{ }. For example, @Requires(expr = "10 > 9")

The following is a set of features it is expected to include, mostly inspired by SpEL with certain adjustments

  1. Declaring literals. These should include
  • strings (delimited by single quotes)
  • numeric types (int, long, float, double, incl. scientific notation and hex)
  • boolean
  • null
  1. Mathematical operators
  • -, /, *, %,^ on numeric types
  • + on both numeric types and strings
  1. Relational operators
  • Standart relational operators (>, <, >=, <=, ==, !=)
  • instanceof operator (e.g. 'abc' instanceof T(java.langString))
  • matches for regex mathing (e.g. 'abc' matches '^[a-z]*') (including /.+/ syntax for regex)
  • empty for checking of an object is null or empty
  • Relational operators need to support comparables as well
  1. Logical operators
  • &&, ||, !. We should probably also allow using aliases here (and, or, not)
  1. Working with type references

Type reference should be provided in format of T(io.micronaut.CustomType) (allowing to omit full package for java.lang classes) and should be treated in different way depending on context

  • as part of instanceof operation
  • as an argument for method invocation which is treated as io.micronaut.CutsomType.class (e.g. getBean(T(io.micronaut.CustomType))
  • as a type reference followed by static method invocation (e.g. T(java.lang.Math).random())
  1. Inlining collections
  • lists with {1, 2, 3} syntax
  • maps with { 'first': 1, 'second': 2 }

Nested collections should also be supported

  1. Working with collections
  • Accessing by index for lists (e.g. list[1])
  • Accessing by key for maps (e.g. map['key'])
  • Collection filtering (e.g. ages[age > 18] )
  • Collection mapping (e.g. persons[person.age] )
  1. Accessing object properties
  • This should allow chaining property access with . (e.g. object.property.nestedProperty)
  • Safe property access allowing to avoid NPE (object?.property.?.nestedProperty)
  1. Methods invocation
  • Object method invocation (e.g. object.name().lenght())
  • Static method invocation (T(java.lang.Math).random())
  1. Ternary operator
  • Standart expr ? 'trueResult' : 'falseResult'
  • Elvis operator (e.g. age?:18)
  1. Object construction
  • construction with new keyword (this might be useful in @Value annotation, e.g. @Value(#{ new io.micronaut.CustomType() })
  • array construcation (new int[] {1, 2, 3}) - not sure whether this is a useful one, since inlining list gives similar behavior
  1. Predefined syntax constructs, e.g.
  • Getting beans from context (e.g. ctx[T(io.micronaut.CustomBean)])
  • Accessing properties (e.g. properties['custom.property' ] )
  1. Making it extendable. This is an interesting one. SpEl uses the term EvaluationContext against which expressions are evaluated. Evaluating against context allows referencing functions and variables registered in evaluation context using the # sigh followed by function invocation/variable name. New context members can be registered at runtime.

In case of micronaut it has to be implemented in different way. One option would be to annotate a class with a special annotation e.g. @EvaluatedExpressionContext making the annotated class a bean itself. At compilation time we can find annotated classes and build a compile-time evaluation context allowing fields/methods/properties be referenced in expressions using # symbol. Under the hood referencing context method/field/property can be compiled into obtaining bean from context and accessing references element.

So in general that might look like this

@EvaluatedExpressionContext
class CustomContext {

       public Random randomizer() {
            return new Random()
       }
}

class UsingContext {
     
      @Scheduled(initialDelay = "#{  #randomizer().nextInt() + 's'  }")
      public void scheduledMethod() {
           // ...
      }
}

which can be compiled into something like

public Object doEvaluate() {
    return getBean(CustomContext.class).randomizer().nextInt() + "s"
}

To make context classes from other modules available, a service descriptor reference can be generated so other modules will be able to discover context classes at compile time. However, that will require to include these modules as annotationProcessors, which can be a downside. Anyway, this is only a concept of how that might look like, other options can be discussed as well.

Features of SpEL not mentined above

  1. Bean references using @ symbol followed by bean name. Since micronaut treat bean names as qualifiers I don't think that is required and can be implemented.
  2. Variable assignment allowing to assign values to variables in EvaluationContext. Don't think it is required as well

I'll link a PR to this issue where I partly implemented points 1, 2, 3, 4, 5, 8, 9, 13 as a proof of concept without diving too deep into details. That PR can be a starting point

@graemerocher
Copy link
Contributor

Thanks for the write-up, this is a good start. Some thoughts:

The context should be specified on the annotation member, so you would have something like:

public @interface Scheduled {
     @EvaluatedExpressionContext(CustomContext.class)
     String initialDelay() default "";
}

It could be on the annotation or the member level.

One that is critical vs SpEL is that we want expression errors to fail at compilation time with errors to javac.

In addition we should be able to create a context stub from a method signature. So for example if you have:

@Secured("#{prinicipal.name == 'Fred'}")
@Get
String hello(Principal principal) {
    ...
}

A binding is constructed with the types and names from the method parameters.

For bean references it would be good to lookup beans by name but that could be something like ctx[T(io.micronaut.CustomBean, 'foo')]

@GavrilovSV
Copy link
Contributor Author

@graemerocher
Wanted to clarify some nuances regarding the concept of EvaluationContext. If we allow to specify context for :

  • annotation members
  • annotations themselves
  • methods annotated with expressions
  • custom user-defined classes annoted with ExpressionEvaluationContext

then it seems like some sort of resolution hierarchy has to be introduced here for cases when there are clashing names in evaluation context (e.g. method parameter has the same name as some property in annotation-level EvaluationContext).

So it looks like precedence should be the following:
method parameter > annotation member context > annotation level context > custom user-defined context

Probably some way of referencing higher level of hierarcy should be provided as well. For instance, Spring allows to refer #root and #this contexts, but I'm not sure what could be considered as root and this if we intoduce similar approach.

Regarding constructs for getting beans from context (ctx[T(io.micronaut.CustomBean, 'foo')]), since this is custom syntax, I think we can introduce named argumens to allow expressions like ctx[T(io.micronaut.CustomBean, named = 'foo')] and ctx[T(io.micronaut.CustomBean, qualifier = T(my.custom.Qualifier))]

@graemerocher
Copy link
Contributor

I would say if there are any conflicts there should be a compilation error

@graemerocher graemerocher added the type: enhancement New feature or request label Nov 23, 2022
graemerocher added a commit that referenced this issue Mar 25, 2023
From feature request #8323 this PR implements the following set of features (some parts will remain unimplemented in the first version):

1.  Declaring literals. 

- [x]  strings (delimited by single quotes) 
- [x] numeric types (int, long, float, double, incl. scientific notation and hex) 
- [x] boolean
- [x] `null`

2. Mathematical operators
- [x] `-`, `/`, `*`, `%`,`^` on numeric types
- [x] `+` on both numeric types and strings 

3. Relational operators
- [x] Standart relational operators (`>`, `<`, `>=`, `<=`, `==`, `!=`)
- [x] `instanceof` operator (e.g. `'abc' instanceof T(java.langString)`)
- [x] `matches` for regex mathing (e.g. `'abc' matches '^[a-z]*'`) (including `/.+/` syntax for regex)
- [x] `empty` for checking of an object is null or empty
- [ ]  Relational operators need to support comparables as well

 4. Logical operators
- [x]  `&&`, `||`, `!`. We should probably also allow using aliases here (`and`, `or`, `not`)

 5. Working with type references
 
- [x]  as part of `instanceof` operation
- [x]  as an argument for method invocation which is treated as io.micronaut.CutsomType.class (e.g. `getBean(T(io.micronaut.CustomType))`
- [x] as a type reference followed by static method invocation (e.g. `T(java.lang.Math).random()`)
   
6. Working with collections
- [x] Accessing by index for lists (e.g. `list[1]`)
- [x] Accessing by key for maps (e.g. `map['key']`)
- [ ] Collection filtering (e.g. `ages.filter(age -> age > 18)` )
- [ ] Collection mapping (e.g. `persons.map(p -> p.age)` )

For filtering/mapping probably makes sense to support lambda syntax.

7. Accessing object properties
- [x] This should allow chaining property access with `.` (e.g. `object.property.nestedProperty`)
- [x] Safe property access allowing to avoid NPE (`object?.property.?.nestedProperty`)

8. Methods invocation
- [x] Object method invocation (e.g. `object.name().lenght()`)
- [x] Static method invocation (`T(java.lang.Math).random()`)

9. Ternary operator
- [x] Standard `expr ? 'trueResult' : 'falseResult'`
- [x] Elvis operator (e.g. `age?:18`, note only makes sense if we implement coercion to boolean for numbers, lists, strings etc.)

10. Object construction
- [ ] construction with `new` keyword (this might be useful in `@Value` annotation, e.g. `@Value(#{ new io.micronaut.CustomType() })` 
- [ ] array construcation (`new int[] {1, 2, 3}`) - not sure whether this is a useful one, since inlining list gives similar behavior 
- [ ] lists with `{1, 2, 3}` syntax
- [ ] maps with `{ 'first': 1, 'second': 2 }`

11. Predefined syntax constructs, e.g.
- [ ] Getting beans from context (e.g. `ctx[io.micronaut.CustomBean]`)
- [ ] Accessing properties (e.g. `env['custom.property']` )

See #8323

---------

Co-authored-by: Sergey Gavrilov <[email protected]>
Co-authored-by: Sergio del Amo <[email protected]>
@GavrilovSV
Copy link
Contributor Author

Now when the basic implementation is merged in #8954, I've turned the original list of features into checklist.

I'm planning to provide implementation for the following features:

  • math operations on BigDecimal and BigInteger types
  • relational operators for Comparable types
  • Getting beans from context (e.g. ctx[T(io.micronaut.CustomBean)])
  • Accessing properties (e.g. env['custom.property' ] )

Then I can also work on collections filtering and mapping, inlining lists and maps

Regarding object construction with the new keyword, I would omit that point, as direct object instantiation inside expressions feels like bad practice in my opinion

What I also think is needed is support for expressions in @Requires annotation with separate annotation member like expr, so it would look like @Requires(expr = ...). I was also thinking that in this case (since expression is assumed as annotation member value), we may allow to make #{ ... } wrapping optional

@graemerocher
Copy link
Contributor

I agree we can skip the new keyword. What do you mean by inlining lists and maps?

Adding @Requires(expr=) seems like a good idea to me.

@graemerocher graemerocher added this to the 4.0.0-M2 milestone Mar 28, 2023
@graemerocher
Copy link
Contributor

@GavrilovSV Going to close this, please create individual PRs for each enhancement. Thanks.

@GavrilovSV
Copy link
Contributor Author

By inlining lists and maps I mean the ability to declare collections in-place with syntax like { 1, 2, 3 } for lists and { 'a': 1, 'b': 2 } for maps. Actually, thinking more about it, I don't see obvious use-cases for inline maps, but it might be useful for lists if we add some sort of in operator to support expressions like #{ user in {'bob', 'alice'}}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants