Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

x1191 Offer release files as xlsx as alternative to tsv #389

Merged
merged 1 commit into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/main/java/uk/ac/sanger/sccp/stan/ReleaseFileController.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import java.util.*;

import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty;
import static uk.ac.sanger.sccp.utils.BasicUtils.repr;

/**
* Controller for delivering release files (tsv).
Expand All @@ -28,10 +29,22 @@ public ReleaseFileController(ReleaseFileService releaseFileService) {
@RequestMapping(value="/release", method = RequestMethod.GET, produces = "text/tsv")
@ResponseBody
public TsvFile<ReleaseEntry> getReleaseFile(@RequestParam(name="id") List<Integer> ids,
@RequestParam(name="groups", required = false) List<String> groupNames) {
@RequestParam(name="groups", required=false) List<String> groupNames,
@RequestParam(name="type", required=false) String fileType) {
final String filename = filenameForType(fileType);
ReleaseFileContent rfc = releaseFileService.getReleaseFileContent(ids, parseOptions(groupNames));
List<? extends TsvColumn<ReleaseEntry>> columns = releaseFileService.computeColumns(rfc);
return new TsvFile<>("releases.tsv", rfc.getEntries(), columns);
return new TsvFile<>(filename, rfc.getEntries(), columns);
}

protected String filenameForType(String fileType) {
if (nullOrEmpty(fileType) || fileType.equalsIgnoreCase("tsv")) {
return "releases.tsv";
}
if (fileType.equalsIgnoreCase("xlsx")) {
return "releases.xlsx";
}
throw new IllegalArgumentException("Unsupported file type: " + repr(fileType));
}

/**
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/uk/ac/sanger/sccp/utils/BasicUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,16 @@ public static boolean startsWithIgnoreCase(String string, String sub) {
return StringUtils.startsWithIgnoreCase(string, sub);
}

/**
* Does the given string end with the given substring, ignoring its case?
* @param string the containing string
* @param sub the substring
* @return true if {@code string} ends with {@code sub}, ignoring case; false otherwise
*/
public static boolean endsWithIgnoreCase(String string, String sub) {
return StringUtils.endsWithIgnoreCase(string, sub);
}

/**
* Escape the sql-LIKE symbols in a string
* (percent, which is any sequence of characters, underscore, which is any single character,
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/uk/ac/sanger/sccp/utils/tsv/TableFileWriter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package uk.ac.sanger.sccp.utils.tsv;

import java.io.Closeable;
import java.io.IOException;

public interface TableFileWriter extends Closeable {
/**
* Writes the table data to this writer's output stream
* @param data data to write
* @param <C> type representing columns in the table
* @param <V> type representing values in the table
* @exception IOException if a problem happened during writing
*/
<C, V> void write(TsvData<C, V> data) throws IOException;
}
13 changes: 9 additions & 4 deletions src/main/java/uk/ac/sanger/sccp/utils/tsv/TsvFileConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@
import org.jetbrains.annotations.NotNull;
import org.springframework.http.*;
import org.springframework.http.converter.*;
import uk.ac.sanger.sccp.utils.BasicUtils;

import java.io.IOException;
import java.io.OutputStream;

/**
* @author dr6
*/
public class TsvFileConverter extends AbstractHttpMessageConverter<TsvFile<?>> {
public static final MediaType MEDIA_TYPE = new MediaType("text", "tsv");
public static final MediaType TSV_MEDIA_TYPE = new MediaType("text", "tsv"),
XLSX_MEDIA_TYPE = new MediaType("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet");

public TsvFileConverter() {
super(MEDIA_TYPE);
super(TSV_MEDIA_TYPE, XLSX_MEDIA_TYPE);
}

@Override
Expand All @@ -30,9 +33,11 @@ protected TsvFile<?> readInternal(@NotNull Class<? extends TsvFile<?>> cls, @Not

@Override
protected void writeInternal(TsvFile<?> rel, HttpOutputMessage output) throws IOException, HttpMessageNotWritableException {
output.getHeaders().setContentType(MEDIA_TYPE);
boolean useTsv = BasicUtils.endsWithIgnoreCase(rel.getFilename(), "tsv");
output.getHeaders().setContentType(useTsv ? TSV_MEDIA_TYPE : XLSX_MEDIA_TYPE);
output.getHeaders().set("Content-Disposition", "attachment; filename=\"" + rel.getFilename() + "\"");
try (TsvWriter writer = new TsvWriter(output.getBody())) {
OutputStream out = output.getBody();
try (TableFileWriter writer = useTsv ? new TsvWriter(out) : new XlsxWriter(out)) {
writer.write(rel);
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/uk/ac/sanger/sccp/utils/tsv/TsvWriter.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package uk.ac.sanger.sccp.utils.tsv;

import java.io.*;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Iterator;
import java.util.List;

Expand All @@ -12,7 +13,7 @@
* (by default {@code "} is escaped to {@code ""}).
* @author dr6
*/
public class TsvWriter implements Closeable {
public class TsvWriter implements TableFileWriter {
private final char separator;
private final char quote;
private final char quoteEscape;
Expand All @@ -32,6 +33,7 @@ public TsvWriter(OutputStream out, char separator, char quote, char quoteEscape,
this.newline = newline;
}

@Override
public <C, V> void write(TsvData<C, V> data) throws IOException {
final List<? extends C> columns = data.getColumns();
writeLn(columns.stream().map(Object::toString).iterator());
Expand Down
96 changes: 96 additions & 0 deletions src/main/java/uk/ac/sanger/sccp/utils/tsv/XlsxWriter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package uk.ac.sanger.sccp.utils.tsv;

import org.apache.poi.ss.usermodel.*;

import java.io.IOException;
import java.io.OutputStream;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Stream;

/**
* Utility to write data as an xlsx file to an OutputStream.
* @author dr6
*/
public class XlsxWriter implements TableFileWriter {
private final OutputStream out;

public XlsxWriter(OutputStream out) {
this.out = out;
}

@Override
public <C, V> void write(TsvData<C, V> data) throws IOException {
final List<? extends C> columns = data.getColumns();
final int numRows = data.getNumRows();
try (Workbook wb = createWorkbook()) {
Sheet sheet = wb.createSheet();
createRow(sheet, 0, columns.stream().map(Object::toString), createHeadingsStyle(wb));
for (int fileRow = 1; fileRow <= numRows; fileRow++) {
final int dataRow = fileRow - 1;
createRow(sheet, fileRow, columns.stream()
.map(column -> valueToString(data.getValue(dataRow, column))),
null);
}
wb.write(out);
}
}

/** Creates a new Xssf workbook from the POI factory */
public Workbook createWorkbook() throws IOException {
return WorkbookFactory.create(true);
}

/** Creates a style suitable for headings in the given workbook */
protected CellStyle createHeadingsStyle(Workbook wb) {
CellStyle style = wb.createCellStyle();
Font headingsFont = wb.createFont();
headingsFont.setBold(true);
style.setFont(headingsFont);
return style;
}

/**
* Adds a row to the given sheet
* @param sheet the sheet to modify
* @param rowIndex the index of the row to add
* @param values the values to put in the row
* @param style the style to use for the row and its cells (may be null)
* @return the new row
*/
protected Row createRow(Sheet sheet, int rowIndex, Stream<String> values, CellStyle style) {
Row row = sheet.createRow(rowIndex);
if (style!=null) {
row.setRowStyle(style);
}
Iterator<String> iter = values.iterator();
int columnIndex = 0;
while (iter.hasNext()) {
String value = iter.next();
Cell cell = row.createCell(columnIndex, CellType.STRING);
if (style!=null) {
cell.setCellStyle(style);
}
if (value != null) {
cell.setCellValue(value);
}
++columnIndex;
}
return row;
}

/**
* Null-ignoring toString method
* @param value object to convert
* @return null if the object is null, otherwise the result of calling {@code toString()} on it
*/
protected static String valueToString(Object value) {
return (value==null ? null : value.toString());
}

/** Closes the underlying output stream */
@Override
public void close() throws IOException {
out.close();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ private void recordStain(Labware lw, StainType st, String bondBarcode, Integer r
stainTypeRepo.saveOperationStainTypes(op.getId(), List.of(st));
}
Slot slot = lw.getFirstSlot();
Sample sample = slot.getSamples().get(0);
Sample sample = slot.getSamples().getFirst();
actionRepo.save(new Action(null, op.getId(), slot, slot, sample, sample));
lwNoteRepo.save(new LabwareNote(null, lw.getId(), op.getId(), "Bond barcode", bondBarcode));
if (rnaPlex!=null) {
Expand Down
151 changes: 151 additions & 0 deletions src/test/java/uk/ac/sanger/sccp/utils/tsv/TestXlsxWriter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package uk.ac.sanger.sccp.utils.tsv;

import org.apache.poi.ss.usermodel.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;

import java.io.IOException;
import java.io.OutputStream;
import java.util.*;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static uk.ac.sanger.sccp.stan.Matchers.streamCaptor;

/**
* Test {@link XlsxWriter}
*/
public class TestXlsxWriter {
enum TestColumn {
Alpha, Beta, Gamma
}

private OutputStream out;
private XlsxWriter writer;

@BeforeEach
void setup() {
out = mock(OutputStream.class);
writer = spy(new XlsxWriter(out));
}

@SuppressWarnings("unchecked")
TsvData<TestColumn, String> mockData() {
TsvData<TestColumn, String> data = mock(TsvData.class);
doReturn(Arrays.asList(TestColumn.values())).when(data).getColumns();
when(data.getNumRows()).thenReturn(2);
when(data.getValue(anyInt(), any())).then(invocation -> {
int rowIndex = invocation.getArgument(0);
TestColumn column = invocation.getArgument(1);
if (column==TestColumn.Gamma) {
return null;
}
return column.name() + rowIndex;
});
return data;
}

@Test
void testWrite() throws IOException {
TsvData<TestColumn, String> data = mockData();
Workbook wb = mock(Workbook.class);
doReturn(wb).when(writer).createWorkbook();
Sheet sheet = mock(Sheet.class);
doReturn(sheet).when(wb).createSheet();
CellStyle style = mock(CellStyle.class);
doReturn(style).when(writer).createHeadingsStyle(wb);
doReturn(null).when(writer).createRow(any(), anyInt(), any(), any());

writer.write(data);

verify(writer).createWorkbook();
verify(wb).createSheet();
ArgumentCaptor<Stream<String>> headingsCaptor = streamCaptor();
verify(writer).createRow(same(sheet), eq(0), headingsCaptor.capture(), same(style));
assertThat(headingsCaptor.getValue()).containsExactly("Alpha", "Beta", "Gamma");

for (int i = 1; i <= 2; ++i) {
ArgumentCaptor<Stream<String>> rowCaptor = streamCaptor();
verify(writer).createRow(same(sheet), eq(i), rowCaptor.capture(), isNull());
if (i==1) {
assertThat(rowCaptor.getValue()).containsExactly("Alpha0", "Beta0", null);
} else {
assertThat(rowCaptor.getValue()).containsExactly("Alpha1", "Beta1", null);
}
}

verify(wb).write(out);
}

@Test
void testCreateWorkbook() throws IOException {
assertNotNull(writer.createWorkbook());
}

@Test
void testCreateHeadingsStyle() {
Workbook wb = mock(Workbook.class);
CellStyle style = mock(CellStyle.class);
Font font = mock(Font.class);
when(wb.createCellStyle()).thenReturn(style);
when(wb.createFont()).thenReturn(font);

assertSame(style, writer.createHeadingsStyle(wb));
verify(wb).createCellStyle();
verify(wb).createFont();
verify(font).setBold(true);
verify(style).setFont(font);
}

@ParameterizedTest
@ValueSource(booleans={false,true})
void testCreateRow(boolean hasStyle) {
Sheet sheet = mock(Sheet.class);
final int rowIndex = 5;
Stream<String> values = Stream.of("Alpha", "Beta", null);
CellStyle style = (hasStyle ? mock(CellStyle.class) : null);
Row row = mock(Row.class);
when(sheet.createRow(anyInt())).thenReturn(row);
final List<Cell> cells = new ArrayList<>(2);
when(row.createCell(anyInt(), any())).then(invocation -> {
Cell cell = mock(Cell.class);
cells.add(cell);
return cell;
});

assertSame(row, writer.createRow(sheet, rowIndex, values, style));
verify(sheet).createRow(rowIndex);
if (style==null) {
verify(row, never()).setRowStyle(any());
} else {
verify(row).setRowStyle(style);
}
assertThat(cells).hasSize(3);
verify(row).createCell(0, CellType.STRING);
verify(row).createCell(1, CellType.STRING);
verify(row).createCell(2, CellType.STRING);
verifyNoMoreInteractions(row);
cells.forEach(style==null ? (cell -> verify(cell, never()).setCellStyle(any())) : (cell -> verify(cell).setCellStyle(style)));
verify(cells.get(0)).setCellValue("Alpha");
verify(cells.get(1)).setCellValue("Beta");
verify(cells.get(2), never()).setCellValue(any(String.class));
}

@Test
void testValueToString() {
assertEquals("1", XlsxWriter.valueToString(1));
assertEquals("Alpha", XlsxWriter.valueToString(TestColumn.Alpha));
assertNull(XlsxWriter.valueToString(null));
}

@Test
void testClose() throws IOException {
writer.close();
verify(out).close();
}
}
Loading