diff --git a/pom.xml b/pom.xml
index 3b61944..0be9c3b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -13,6 +13,7 @@
quarkus-json-service
quarkus-message-digest-service
quarkus-random-number-generator
+ quarkus-table-generator
quarkus-tracing-service
quarkus-uuid-generator
diff --git a/quarkus-table-generator/pom.xml b/quarkus-table-generator/pom.xml
new file mode 100644
index 0000000..2948656
--- /dev/null
+++ b/quarkus-table-generator/pom.xml
@@ -0,0 +1,47 @@
+
+
+ 4.0.0
+
+
+ ch.phoenix.oss
+ quarkus-commons
+ 1.0.9-SNAPSHOT
+
+
+ quarkus-table-generator
+ jar
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco-plugin.version}
+
+
+ jacoco-check
+
+ check
+
+ test
+
+ ${project.build.directory}/jacoco-quarkus.exec
+
+
+ BUNDLE
+
+
+ INSTRUCTION
+ COVEREDRATIO
+ 1
+
+
+
+
+
+
+
+
+
+
+
diff --git a/quarkus-table-generator/src/main/java/ch/phoenix/oss/quarkus/commons/table/TableGenerator.java b/quarkus-table-generator/src/main/java/ch/phoenix/oss/quarkus/commons/table/TableGenerator.java
new file mode 100644
index 0000000..3a30730
--- /dev/null
+++ b/quarkus-table-generator/src/main/java/ch/phoenix/oss/quarkus/commons/table/TableGenerator.java
@@ -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 notation
+ *
+ * @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 headers);
+}
diff --git a/quarkus-table-generator/src/main/java/ch/phoenix/oss/quarkus/commons/table/TableGeneratorImpl.java b/quarkus-table-generator/src/main/java/ch/phoenix/oss/quarkus/commons/table/TableGeneratorImpl.java
new file mode 100644
index 0000000..e05e3c4
--- /dev/null
+++ b/quarkus-table-generator/src/main/java/ch/phoenix/oss/quarkus/commons/table/TableGeneratorImpl.java
@@ -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 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 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>();
+ for (var record : records) {
+ var row = new ArrayList(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();
+ }
+}
diff --git a/quarkus-table-generator/src/test/java/ch/phoenix/oss/quarkus/commons/table/TableGeneratorImplTest.java b/quarkus-table-generator/src/test/java/ch/phoenix/oss/quarkus/commons/table/TableGeneratorImplTest.java
new file mode 100644
index 0000000..6f90dfe
--- /dev/null
+++ b/quarkus-table-generator/src/test/java/ch/phoenix/oss/quarkus/commons/table/TableGeneratorImplTest.java
@@ -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 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 testDtos, List 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");
+ }
+}