Skip to content

Commit

Permalink
Qute - prefix iteration metadata in a loop
Browse files Browse the repository at this point in the history
- the prefix is configurable (globally)
- also added "isFirst" and "isLast" properties for convenience
- resolves quarkusio#20671
  • Loading branch information
mkouba committed Oct 26, 2021
1 parent ab9ff3d commit 4435e7b
Show file tree
Hide file tree
Showing 32 changed files with 366 additions and 140 deletions.
53 changes: 41 additions & 12 deletions docs/src/main/asciidoc/qute-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -557,26 +557,55 @@ The other form is using the `for` name and can specify the alias used to referen
----
<1> `item` is the alias used for the iteration element.

It's also possible to access the iteration metadata inside the loop:
It's also possible to access the iteration metadata inside the loop via the following keys:

* `count` - 1-based index
* `index` - zero-based index
* `hasNext` - `true` if the iteration has more elements
* `isLast` - `true` if `hasNext == false`
* `isFirst` - `true` if `count == 1`
* `odd` - `true` if the zero-based index is odd
* `even` - `true` if the zero-based index is even
* `indexParity` - outputs `odd` or `even` based on the zero-based index value

However, the keys cannot be used directly.
Instead, a prefix is used to avoid possible collisions with variables from the outer scope.
By default, the alias of an iterated element suffixed with an underscore is used as a prefix.
For example, the `hasNext` key must be prefixed with `it_` inside an `{#each}` section: `{it_hasNext}`.

.`each` Iteration Metadata Example
[source]
----
{#each items}
{it_count}. {it.name} <1>
{#if it_hasNext}<br>{/if} <2>
{/each}
----
<1> `it_count` represents one-based index.
<2> `<br>` is only rendered if the iteration has more elements.

* `{count}` - 1-based index
* `{index}` - zero-based index
* `{hasNext}` - `true` if the iteration has more elements
* `{odd}` - `true` if the zero-based index is odd
* `{even}` - `true` if the zero-based index is even
* `{indexParity}` - outputs `odd` or `even` based on the zero-based index value
And must be used in a form of `{item_hasNext}` inside a `{#for}` section with the `item` element alias.

.Iteration Metadata Example
.`for` Iteration Metadata Example
[source]
----
{#each items}
{count}. {it.name} <1>
{#if hasNext}<br>{/if} <2>
{#for item in items}
{item_count}. {item.name} <1>
{#if item_hasNext}<br>{/if} <2>
{/each}
----
<1> `count` represents one-based index.
<1> `item_count` represents one-based index.
<2> `<br>` is only rendered if the iteration has more elements.

[TIP]
====
The iteration metadata prefix is configurable either via `EngineBuilder.iterationMetadataPrefix()` for standalone Qute or via the `io.quakurs.qute.iteration-metadata-prefix` configuration property in a Quarkus application. Three special constants can be used:
1. `<alias_>` - the alias of an iterated element suffixed with an underscore is used (default)
2. `<alias?>` - the alias of an iterated element suffixed with a question mark is used
3. `<none>` - no prefix is used
====

The `for` statement also works with integers, starting from 1. In the example below, considering that `total = 3`:

[source]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
<tbody>
{#for bean in info:devBeanInfos.beans}
<tr>
<td>{count}.</td>
<td>{bean_count}.</td>
<td>
{#display-bean bean/}
</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
<tbody>
{#for interceptor in info:devBeanInfos.interceptors}
<tr>
<td>{count}.</td>
<td>{interceptor_count}.</td>
<td><span class="class-candidate">{interceptor.interceptorClass}</span></td>
<td><span class="badge rounded-pill bg-info text-light larger-badge" title="Priority: {interceptor.priority}">{interceptor.priority}</span></td>
<td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
<tbody>
{#for observer in info:devBeanInfos.observers}
<tr>
<td>{count}.</td>
<td>{observer_count}.</td>
<td>
{#if observer.declaringClass}
<span class="class-candidate">{observer.declaringClass}</span>#{observer.methodName}()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<tbody>
{#for bean in info:devBeanInfos.removedBeans}
<tr>
<td>{count}.</td>
<td>{bean_count}.</td>
<td>
{#display-bean bean/}
</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<tbody>
{#for service in info:grpcServices.infos}
<tr>
<td>{count}.</td>
<td>{service_count}.</td>
<td>
{#when service.status}
{#is SERVING}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ <h4><span class="badge">Persistence Unit</span> <i class="badge badge-info">{pu.
<tbody>
{#for entity in pu.managedEntities}
<tr>
<td>{count}.</td>
<td>{entity_count}.</td>
<td>{entity.className}</td>
<td>{entity.tableName}</td>
</tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ <h4><span class="badge">Persistence Unit</span> <i class="badge badge-info">{pu.
<tbody>
{#for query in pu.allNamedQueries}
<tr>
<td>{count}.</td>
<td>{query_count}.</td>
<td>{query.name}</td>
<td>{query.query}</td>
<td>{query.lockMode}</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,37 +32,37 @@
<thead class="thead-light">
<tr>
<th scope="col">
<a href="#" onclick="toggleExpanded(this, 'td-create-script-{count}'); return false;" class="script-heading">
<a href="#" onclick="toggleExpanded(this, 'td-create-script-{unit_count}'); return false;" class="script-heading">
<span class="fa fa-chevron-right icon"></span>
<span>Create Script</span>
</a>
<a href="#" onclick="copyToClipboard('create-script-{count}'); return false;" class="float-right badge">
<a href="#" onclick="copyToClipboard('create-script-{unit_count}'); return false;" class="float-right badge">
<span class="fa fa-clipboard"></span> Copy</a>
</th>
</tr>
</thead>
<tbody>
<tr>
<td id="td-create-script-{count}" class="hidden">
<pre id="create-script-{count}" class="ddl-script">{pu.createDDL}</pre>
<td id="td-create-script-{unit_count}" class="hidden">
<pre id="create-script-{unit_count}" class="ddl-script">{pu.createDDL}</pre>
</td>
</tr>
<thead class="thead-light">
<tr>
<th scope="col">
<a href="#" onclick="toggleExpanded(this, 'td-drop-script-{count}'); return false;" class="script-heading">
<a href="#" onclick="toggleExpanded(this, 'td-drop-script-{unit_count}'); return false;" class="script-heading">
<span class="fa fa-chevron-right icon"></span>
<span>Drop Script</span>
</a>
<a href="#" onclick="copyToClipboard('drop-script-{count}'); return false;" class="float-right badge">
<a href="#" onclick="copyToClipboard('drop-script-{unit_count}'); return false;" class="float-right badge">
<span class="fa fa-clipboard"></span> Copy</a>
</th>
</tr>
</thead>
<tbody>
<tr>
<td id="td-drop-script-{count}" class="hidden">
<pre id="drop-script-{count}" class="ddl-script">{pu.dropDDL}</pre>
<td id="td-drop-script-{unit_count}" class="hidden">
<pre id="drop-script-{unit_count}" class="ddl-script">{pu.dropDDL}</pre>
</td>
</tr>
</tbody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ public String apply(String id) {
for (Expression param : params) {
if (param.hasTypeInfo()) {
Map<String, Match> results = new HashMap<>();
QuteProcessor.validateNestedExpressions(exprEntry.getKey(), defaultBundleInterface,
QuteProcessor.validateNestedExpressions(config, exprEntry.getKey(), defaultBundleInterface,
results, excludes, incorrectExpressions, expression, index,
implicitClassToMembersUsed, templateIdToPathFun, generatedIdsToMatches,
checkedTemplate, lookupConfig, namedBeans, namespaceTemplateData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ public class QuteProcessor {
private static final String CHECKED_TEMPLATE_REQUIRE_TYPE_SAFE = "requireTypeSafeExpressions";
private static final String CHECKED_TEMPLATE_BASE_PATH = "basePath";

private static final Set<String> ITERATION_METADATA_KEYS = Set.of("count", "index", "indexParity", "hasNext", "odd",
"isOdd", "even", "isEven", "isLast", "isFirst");

private static final Function<FieldInfo, String> GETTER_FUN = new Function<FieldInfo, String>() {
@Override
public String apply(FieldInfo field) {
Expand Down Expand Up @@ -521,7 +524,8 @@ public String apply(String id) {
if (expression.isLiteral()) {
continue;
}
Match match = validateNestedExpressions(templateAnalysis, null, new HashMap<>(), excludes, incorrectExpressions,
Match match = validateNestedExpressions(config, templateAnalysis, null, new HashMap<>(), excludes,
incorrectExpressions,
expression, index, implicitClassToMembersUsed, templateIdToPathFun, generatedIdsToMatches,
checkedTemplate, lookupConfig, namedBeans, namespaceTemplateData, regularExtensionMethods,
namespaceExtensionMethods);
Expand Down Expand Up @@ -581,7 +585,8 @@ static String buildIgnorePattern(Iterable<String> names) {
return ignorePattern.toString();
}

static Match validateNestedExpressions(TemplateAnalysis templateAnalysis, ClassInfo rootClazz, Map<String, Match> results,
static Match validateNestedExpressions(QuteConfig config, TemplateAnalysis templateAnalysis, ClassInfo rootClazz,
Map<String, Match> results,
List<TypeCheckExcludeBuildItem> excludes, BuildProducer<IncorrectExpressionBuildItem> incorrectExpressions,
Expression expression, IndexView index,
Map<DotName, Set<String>> implicitClassToMembersUsed, Function<String, String> templateIdToPathFun,
Expand All @@ -596,7 +601,7 @@ static Match validateNestedExpressions(TemplateAnalysis templateAnalysis, ClassI
if (part.isVirtualMethod()) {
for (Expression param : part.asVirtualMethod().getParameters()) {
if (!results.containsKey(param.toOriginalString())) {
validateNestedExpressions(templateAnalysis, null, results, excludes,
validateNestedExpressions(config, templateAnalysis, null, results, excludes,
incorrectExpressions, param, index, implicitClassToMembersUsed, templateIdToPathFun,
generatedIdsToMatches, checkedTemplate, lookupConfig, namedBeans, namespaceTemplateData,
regularExtensionMethods, namespaceExtensionMethods);
Expand Down Expand Up @@ -641,14 +646,39 @@ static Match validateNestedExpressions(TemplateAnalysis templateAnalysis, ClassI
}

if (checkedTemplate != null && checkedTemplate.requireTypeSafeExpressions && !expression.hasTypeInfo()) {
incorrectExpressions.produce(new IncorrectExpressionBuildItem(expression.toOriginalString(),
"Only type-safe expressions are allowed in the checked template defined via: "
+ checkedTemplate.method.declaringClass().name() + "."
+ checkedTemplate.method.name()
+ "(); an expression must be based on a checked template parameter "
+ checkedTemplate.bindings.keySet()
+ ", or bound via a param declaration, or the requirement must be relaxed via @CheckedTemplate(requireTypeSafeExpressions = false)",
expression.getOrigin()));
if (!expression.hasNamespace() && expression.getParts().size() == 1
&& ITERATION_METADATA_KEYS.contains(expression.getParts().get(0).getName())) {
String prefixInfo;
if (config.iterationMetadataPrefix
.equals(LoopSectionHelper.Factory.ITERATION_METADATA_PREFIX_ALIAS_UNDERSCORE)) {
prefixInfo = String.format(
"based on the iteration alias, i.e. the correct key should be something like {it_%1$s} or {element_%1$s}",
expression.getParts().get(0).getName());
} else if (config.iterationMetadataPrefix
.equals(LoopSectionHelper.Factory.ITERATION_METADATA_PREFIX_ALIAS_QM)) {
prefixInfo = String.format(
"based on the iteration alias, i.e. the correct key should be something like {it?%1$s} or {element?%1$s}",
expression.getParts().get(0).getName());
} else {
prefixInfo = ": " + config.iterationMetadataPrefix + ", i.e. the correct key should be: "
+ config.iterationMetadataPrefix + expression.getParts().get(0).getName();
}
incorrectExpressions.produce(new IncorrectExpressionBuildItem(expression.toOriginalString(),
"An invalid iteration metadata key is probably used\n\t- The configured iteration metadata prefix is "
+ prefixInfo
+ "\n\t- You can configure the prefix via the io.quarkus.qute.iteration-metadata-prefix configuration property",
expression.getOrigin()));
} else {
incorrectExpressions.produce(new IncorrectExpressionBuildItem(expression.toOriginalString(),
"Only type-safe expressions are allowed in the checked template defined via: "
+ checkedTemplate.method.declaringClass().name() + "."
+ checkedTemplate.method.name()
+ "(); an expression must be based on a checked template parameter "
+ checkedTemplate.bindings.keySet()
+ ", or bound via a param declaration, or the requirement must be relaxed via @CheckedTemplate(requireTypeSafeExpressions = false)",
expression.getOrigin()));

}
return putResult(match, results, expression);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
{#if template.parameters}
testData{dataCount} = '{';
{#each template.parameters}
testData{dataCount} += '\n // Template parameter {count}: {it.value.raw}\n';
testData{dataCount} += '\n // Template parameter {template_count}: {it.value.raw}\n';
testData{dataCount} += ' "{it.key}" : null';
{#if hasNext}
testData{dataCount} += ',';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<tbody>
{#for template in info:devQuteInfos.templates}
<tr>
<td>{count}.</td>
<td>{template_count}.</td>
<td>
{template.path}
</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ public class CheckedTemplateRequireTypeSafeTest {
+ "{any} "
+ "{inject:fool.getJoke(identifier)} "
+ "{#each name.chars.iterator}"
+ "{! {index} is not considered an error because the binding is registered by the loop section !}"
+ "{index}. {it}"
// {it_index} is not considered an error because the binding is registered by the loop section !}
+ "{it_index}."
// however, {index} is an error
+ "{index}"
+ "{it}"
+ "{/each}"),
"templates/CheckedTemplateRequireTypeSafeTest/hola.txt"))
.assertException(t -> {
Expand All @@ -44,9 +47,10 @@ public class CheckedTemplateRequireTypeSafeTest {
e = e.getCause();
}
assertNotNull(te);
assertTrue(te.getMessage().contains("Found template problems (2)"), te.getMessage());
assertTrue(te.getMessage().contains("Found template problems (3)"), te.getMessage());
assertTrue(te.getMessage().contains("any"), te.getMessage());
assertTrue(te.getMessage().contains("identifier"), te.getMessage());
assertTrue(te.getMessage().contains("index"), te.getMessage());
});

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class ParamDeclarationTest {
.addAsResource(new StringAsset("{@io.quarkus.qute.deployment.typesafe.Movie movie}"
+ "{movie.mainCharacters.size}: {#for character in movie.mainCharacters}"
+ "{character}"
+ "{#if hasNext}, {/}"
+ "{#if character_hasNext}, {/}"
+ "{/}"), "templates/movie.html"));

@Inject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ public EngineProducer(QuteContext context, QuteConfig config, QuteRuntimeConfig
// Remove standalone lines if desired
builder.removeStandaloneLines(runtimeConfig.removeStandaloneLines);

// Iteration metadata prefix
builder.iterationMetadataPrefix(config.iterationMetadataPrefix);

// Allow anyone to customize the builder
builderReady.fire(builder);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,21 @@ public class QuteConfig {
@ConfigItem
public Optional<List<String>> typeCheckExcludes;

/**
* The prefix is used to access the iteration metadata inside a loop section.
* <p>
* A valid prefix consists of alphanumeric characters and underscores.
* Three special constants can be used:
* <ul>
* <li>{@code <alias_>} - the alias of an iterated element suffixed with an underscore is used, e.g. {@code item_hasNext}
* and {@code it_count}</li>
* <li>{@code <alias?>} - the alias of an iterated element suffixed with a question mark is used, e.g. {@code item?hasNext}
* and {@code it?count}</li>
* <li>{@code <none>} - no prefix is used, e.g. {@code hasNext} and {@code count}</li>
* </ul>
* By default, the {@code <alias_>} constant is set.
*/
@ConfigItem(defaultValue = "<alias_>")
public String iterationMetadataPrefix;

}
Loading

0 comments on commit 4435e7b

Please sign in to comment.