From 8617ff36c63aca33a477b19c55b8534e40032171 Mon Sep 17 00:00:00 2001 From: Jorge Bornhausen Date: Tue, 5 Aug 2025 21:55:38 +0200 Subject: [PATCH 1/2] feat(table): add quarkus-table-generator --- pom.xml | 1 + .../quarkus/commons/table/TableGenerator.java | 20 ++++ .../commons/table/TableGeneratorImpl.java | 65 +++++++++++ .../commons/table/TableGeneratorImplTest.java | 106 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 quarkus-table-generator/src/main/java/ch/phoenix/oss/quarkus/commons/table/TableGenerator.java create mode 100644 quarkus-table-generator/src/main/java/ch/phoenix/oss/quarkus/commons/table/TableGeneratorImpl.java create mode 100644 quarkus-table-generator/src/test/java/ch/phoenix/oss/quarkus/commons/table/TableGeneratorImplTest.java 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/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"); + } +} From 3100279338651e2b6608a7e19dd6b4cc4951c74c Mon Sep 17 00:00:00 2001 From: Jorge Bornhausen Date: Tue, 5 Aug 2025 21:55:38 +0200 Subject: [PATCH 2/2] feat(table): add quarkus-table-generator --- pom.xml | 1 + quarkus-table-generator/pom.xml | 47 ++++++++ .../quarkus/commons/table/TableGenerator.java | 20 ++++ .../commons/table/TableGeneratorImpl.java | 65 +++++++++++ .../commons/table/TableGeneratorImplTest.java | 106 ++++++++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 quarkus-table-generator/pom.xml create mode 100644 quarkus-table-generator/src/main/java/ch/phoenix/oss/quarkus/commons/table/TableGenerator.java create mode 100644 quarkus-table-generator/src/main/java/ch/phoenix/oss/quarkus/commons/table/TableGeneratorImpl.java create mode 100644 quarkus-table-generator/src/test/java/ch/phoenix/oss/quarkus/commons/table/TableGeneratorImplTest.java 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"); + } +}