Compare commits

...

1 commit

Author SHA1 Message Date
3100279338
feat(table): add quarkus-table-generator
All checks were successful
Build / build (pull_request) Successful in 2m7s
2025-08-05 21:58:12 +02:00
5 changed files with 239 additions and 0 deletions

View file

@ -13,6 +13,7 @@
<module>quarkus-json-service</module>
<module>quarkus-message-digest-service</module>
<module>quarkus-random-number-generator</module>
<module>quarkus-table-generator</module>
<module>quarkus-tracing-service</module>
<module>quarkus-uuid-generator</module>
</modules>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>ch.phoenix.oss</groupId>
<artifactId>quarkus-commons</artifactId>
<version>1.0.9-SNAPSHOT</version>
</parent>
<artifactId>quarkus-table-generator</artifactId>
<packaging>jar</packaging>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco-plugin.version}</version>
<executions>
<execution>
<id>jacoco-check</id>
<goals>
<goal>check</goal>
</goals>
<phase>test</phase>
<configuration>
<dataFile>${project.build.directory}/jacoco-quarkus.exec</dataFile>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>1</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View file

@ -0,0 +1,20 @@
package ch.phoenix.oss.quarkus.commons.table;
import java.util.List;
public interface TableGenerator {
/**
* Generates a Jira-style table from a list of records.
* Refer to <a href="https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all">notation</a>
*
* @param records List of records to generate the table from
* @param headers Optional custom headers, list must match record field order and count.
* If null, field names are used as headers.
* @return String representing the records as a table in Jira notation
* @throws IllegalArgumentException If records are null or empty, if the type is not a Record.
* Also throws if custom headers are provided but the size does not match the record's field count
* @throws IllegalStateException if an error occurs while invoking a record's accessor
*/
String generateJiraTable(List<?> records, List<String> headers);
}

View file

@ -0,0 +1,65 @@
package ch.phoenix.oss.quarkus.commons.table;
import jakarta.enterprise.context.ApplicationScoped;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@ApplicationScoped
class TableGeneratorImpl implements TableGenerator {
@Override
public String generateJiraTable(List<?> records, List<String> headers) {
if (records == null || records.isEmpty()) {
throw new IllegalArgumentException("Records can't be null or empty");
}
Class<?> recordClass = records.getFirst().getClass();
if (!recordClass.isRecord()) {
throw new IllegalArgumentException("Only records are supported at the moment");
}
var components = recordClass.getRecordComponents();
var columns = components.length;
List<String> headerRow;
if (headers == null) {
headerRow = Arrays.stream(components).map(RecordComponent::getName).toList();
} else if (headers.size() == columns) {
headerRow = headers;
} else {
throw new IllegalArgumentException("Custom header count [%s] does not match actual number of columns [%s]"
.formatted(headers.size(), columns));
}
var dataRows = new ArrayList<List<String>>();
for (var record : records) {
var row = new ArrayList<String>(columns);
for (var component : components) {
try {
var val = component.getAccessor().invoke(record);
row.add(val == null ? "" : val.toString());
} catch (Exception e) {
throw new IllegalStateException(
"Unable to invoke accessor for component [%s] of class [%s]"
.formatted(component.getName(), recordClass.getName()),
e);
}
}
dataRows.add(row);
}
var sb = new StringBuilder("||");
headerRow.forEach(header -> sb.append(" ").append(header).append(" ||"));
sb.append("\n");
for (var row : dataRows) {
sb.append("|");
for (String cell : row) {
sb.append(" ").append(cell).append(" |");
}
sb.append("\n");
}
return sb.toString();
}
}

View file

@ -0,0 +1,106 @@
package ch.phoenix.oss.quarkus.commons.table;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
@QuarkusTest
class TableGeneratorImplTest {
@Inject
TableGenerator tableGenerator;
public record TestDto(String what, Instant instant, Double number) {}
static Stream<Arguments> generateJiraTable() {
var dtos = List.of(
new TestDto("What", Instant.ofEpochMilli(123).truncatedTo(ChronoUnit.MILLIS), 2.0d),
new TestDto("SupDog", Instant.ofEpochMilli(124313425).truncatedTo(ChronoUnit.MILLIS), null),
new TestDto(null, null, 214534.134d),
new TestDto(null, null, null));
return Stream.of(
arguments(
dtos,
List.of("What", "Instant", "SomeNumber"),
"""
|| What || Instant || SomeNumber ||
| What | 1970-01-01T00:00:00.123Z | 2.0 |
| SupDog | 1970-01-02T10:31:53.425Z | |
| | | 214534.134 |
| | | |
"""),
arguments(
dtos,
null,
"""
|| what || instant || number ||
| What | 1970-01-01T00:00:00.123Z | 2.0 |
| SupDog | 1970-01-02T10:31:53.425Z | |
| | | 214534.134 |
| | | |
"""));
}
@MethodSource
@ParameterizedTest
void generateJiraTable(List<TestDto> testDtos, List<String> headers, String expected) {
var actual = tableGenerator.generateJiraTable(testDtos, headers);
assertThat(actual).as("Generated table should match expected value").isEqualTo(expected);
}
@Test
void generateJiraTableWhenRecordsIsNull() {
assertThatThrownBy(() -> tableGenerator.generateJiraTable(null, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Records can't be null or empty");
}
@Test
void generateJiraTableWhenRecordsIsEmpty() {
assertThatThrownBy(() -> tableGenerator.generateJiraTable(List.of(), null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Records can't be null or empty");
}
@Test
void generateJiraTableWhenNotRecord() {
var notARecord = List.of("just a string");
assertThatThrownBy(() -> tableGenerator.generateJiraTable(notARecord, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Only records are supported");
}
@Test
void generateJiraTableWhenCustomHeaderWrongSize() {
var dtos = List.of(new TestDto("foo", Instant.now(), 1.2d));
assertThatThrownBy(() -> tableGenerator.generateJiraTable(dtos, List.of("One", "Two")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Custom header count [2] does not match actual number of columns [3]");
}
public record EvilRecord(String value) {
@Override
public String value() {
throw new RuntimeException("Accessor failure!");
}
}
@Test
void generateJiraTableWhenAccessorThrows() {
var list = List.of(new EvilRecord("hi"));
assertThatThrownBy(() -> tableGenerator.generateJiraTable(list, null))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Unable to invoke accessor");
}
}