quarkusio / qute

Qute has been included in the main Quarkus repository.
https://github.com/quarkusio/quarkus/
Apache License 2.0
5 stars 5 forks source link

Consider a non-root-based template #4

Open FroMage opened 5 years ago

FroMage commented 5 years ago

The current templates supports a single current root instance that you can inject in your template for rendering. This means all its member properties are available during rendering, but you cannot have more than one root. You can change the root with the with tag, and refer to a different root using namespaces. But namespaces are hardcoded to the tag type they refer to, so you can't refer to any arbitrary scope by namespace if both scopes have the same namespace.

This is something that is common across template systems, but it is very alien to Java developers and their notions of scoping.

We should consider an alternate inspiration for semantics in the form of Java methods and syntax, where a template would take any number of named parameters, all visible in the scope of the entire template.

Every tag would then introduce new scopes, possibly with new bindings, just like Java instructions like if, while, for.

Example such semantics:

// in foo.html
<html>
 <head>
  <title>{title} - {subtitle}</title> <!-- multiple roots -->
  </head>
  <body>
   {#if !users.empty}
    <ul>
     {#each users} <!-- implicit definition of 'user' variable -->
      <li>{user.name}<li>
      <!-- note that here I can still access title/subtitle/users because they're in scope -->
     {#/}
    </ul>
   {#/}
 </body>
</html>

This would be rendered with String render(Map<String,Object> parameters).

With such semantics, we rely on best-choice optional parameters to make life simple, but we can override the defaults if we want, so the following two forms are equivalent ways to build a comma-separated index like 0: Bob, 1: Stef:

     {#each users}
       {#if !user_isFirst}, {#/}{user_index}: {user.name}
     {#/}
     {#each users, var: 'user', isFirstVar: 'isFirst', indexVar: 'index'}
       {#if !isFirst}, {#/}{index}: {user.name}
     {#/}

We can still support a with tag that changes resolving so that any variable is first looked up to be the member of an instance:

     {#each users}
       {user.name} {user.lastName}
     {#/}
<!-- equivalent to: -->
     {#each users}
      {#with user}
       {name} {lastName}
      {#/}
     {#/}

But given the very relative usage of this instructions in modern languages, I'm not sure it will be the most used tag.

We could define scoped new variables with the let tag:

     {#each users}
      {#let expensiveComputation: 2*user.age, friendsCount: user.friends.size()}
       {user.name} is {expensiveComputation} old and has {friendsCount} friends.
      {#/}
     {#/}

But I also think we could just do like in Java and allow declaring new local variables in the current scope:

     {#each users}
      <!-- Those are declared in the current `each` tag scope -->
      {#set expensiveComputation: 2*user.age/}
      {#set friendsCount: user.friends.size()/}
       {user.name} is {expensiveComputation} old and has {friendsCount} friends.
     {#/}

My opinion is that this is much more Java-dev-friendly and flexible than the current option. Note that this is not at all an issue about syntax but about semantics. I will open another issue to discuss syntax.

mkouba commented 5 years ago

In fact, you can pass a Map as the root data object and your foo.html would work as expected (provided the default resolver for Map is registered). Well, almost as expected - title/subtitle/users would need data: namespace inside the each tag.

The problem with nested contexts/scopes is performance -> the engine needs to walk up the hierarchy of contexts and that could be expensive.

I like the idea of let/set tags. I'm not so sure about the "implicit definition of 'user' variable".

Thanks for feedback. Good discussion! ;-)

FroMage commented 5 years ago

The problem with nested contexts/scopes is performance -> the engine needs to walk up the hierarchy of contexts and that could be expensive.

We're talking about a max of 10 nested layers, I think, it's not like that can affect perf in any meaningful way, no?

mkouba commented 5 years ago

You may be surprised ;-). Let's suppose we have 5 nested levels and 15 registered resolvers (components which attempt to resolve a property). Now for the worst case scenario where the property is found in the root level by the last resolver or not found at all - you'll get 5 iterations over 15 resolvers, that's 75 invocations of ValueResolver#appliesTo() just to find the matching resolver. Now if you have a loop over 20 items and for each item you render 3 such properties. It's 20 3 75 = 4500 invocations. For some resolvers this would be negligible but for complex resolvers, e.g. for a resolver generated for a complex class with many methods, this would be measurable in benchmarks...

Now seriously, I like the concept of the current context and namespaces more although it's probably a bit unusual. The reason is that from my experience it's not always easy to identify the scopes in a template. No matter what syntax you use it's usually a mess. On the other, it should be quite easy to identify the current context and for everything else a namespace is needed.

FroMage commented 5 years ago

Let's suppose we have 5 nested levels and 15 registered resolvers (components which attempt to resolve a property)

Well, in the model I'm describing each scope can only add a few bindings, and properties are not directly looked up on those bindings, so a lookup is just asking each layer if it has a binding for a given name, which is much cheaper.

emmanuelbernard commented 4 years ago

Note that if the template becomes fully resolved at build time ("type-safe"), then this invocation cost is moot as the context would be fully resolved by the time the template is generated.

emmanuelbernard commented 4 years ago

Something on that subject, should a template declare the type of its incoming parameters (or root contexts).

{#param items}com.github.mkouba.qute.quarkus.example.Item {/}
{#param limit}java.math.BigDecimal{/}
// if it had a single root param it would be {#param}com.github.mkouba.qute.quarkus.example.Item{/}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style type="text/css">
body {
  font-family: sans-serif;
}
</style>
<title>List of Items</title>
</head>

<body>
    <h1>List of Items</h1>
    <p>We have found {items.size} items.</p>
    <ol>
    {#each items}
        <li><strong>{name.toUpperCase ?: "NO NAME"}</strong>:
        {#if price > data:limit} 
          {discountedPrice.scaled(0)} (<s>{price}</s>)
        {:else}
          {price}
        {/if}</li>
    {/each}
    </ol>
</body>

</html>

This is "a bit" annoying but offers a lot of type safety. Thoughts?

mkouba commented 4 years ago

Note that if the template becomes fully resolved at build time ("type-safe"), then this invocation cost is moot as the context would be fully resolved by the time the template is generated.

Qute doesn't work like this. We don't generate/compile the template during the build. And I see no reason why it should be...

Something on that subject, should a template declare the type of its incoming parameters...

Again, I don't think this would bring any value. Also as I tried to explain in several places - the fact that you're working with java.math.BigDecimal does not mean you're limited to its members. Anyone can add virtual/extension methods in both a declarative and a programmatic way.

FroMage commented 4 years ago

Again, I don't think this would bring any value. Also as I tried to explain in several places - the fact that you're working with java.math.BigDecimal does not mean you're limited to its members. Anyone can add virtual/extension methods in both a declarative and a programmatic way.

I still don't understand why you bring this up: extension methods are type-safe: we know what types they augment, and what methods they bring. This resolution is entirely possible at compile-time, which is how every language with extension method does type-checking.

If you have a BigDecimal, the sum of the methods you can invoke on it are those declared on its types, plus those declared as extension methods for that type. All of which can be checked at compile-time.

mkouba commented 4 years ago

I still don't understand why you bring this up: extension methods...

Ok, again - extension methods are only one way of adding a "virtual member". Also an extension method could be used to handle multiple "keys". See for example: https://github.com/mkouba/qute/blob/master/quarkus/example/src/main/java/com/github/mkouba/qute/quarkus/example/GithubClient.java#L47-L50.

FroMage commented 4 years ago

Well, this is a design choice: we could force extension methods to be type-safe by closing those doors if we want to.

mkouba commented 4 years ago

I don't think it's a good idea to close those doors - I find the reasons to keep them open reasonable ;-).

FroMage commented 4 years ago

Fair enough, and we're straying farther from the current issue, but can you list those use-cases please? I understand your example allows map.key to access map.get(key). Personally I'd have preferred the map[key] notation myself, to allow for key values that conflict with properties or methods from the left-hand-side.

It's totally possible that the benefits are worth it, but it helps if we learn about them :)

mkouba commented 4 years ago

Ok, so in my example the object is io.vertx.core.json.JsonObject which is not a Map but represents a JSON object where it's natural to use the dot notation, i.e. foo.bar. Of course, the bracket notation is also useful and Qute supports it (we have at least one test ;-) - foo.bar is equivalent to foo[bar].

Another use case might be that you would like to expose "properties" of a particular object, i.e. Collection: https://github.com/mkouba/qute/blob/master/core/src/main/java/com/github/mkouba/qute/ValueResolvers.java#L41-L58. Of course, you could either declare an extension method for each property (4 methods in this particular case) or allow the @TemplateExtension to list all properties names and use the names inside the method. But is it elegant and concise?

Another use case might be that you would like to register a resolver dynamically based on some configuration flag.

Listen, I do undestand that "build-time generated type-safe thingy" could bring some benefits. However, Qute is not designed in this way because I don't believe it's very practial. For example, in the dev mode you would have to rebuild/generate the template whenever you change the original source and throw away the previous class (i.e. drop the app classloader). And even if we don't transform a template into bytecode but merely validate it is "type-safe" we would have to re-run all the build steps.

Anyway, it would require some substantial redesign/refactoring. That's all.