From 361e4446470315db2b6bfe395f16a4883311d02e Mon Sep 17 00:00:00 2001 From: Jon Knight Date: Tue, 21 Apr 2026 11:12:33 +0100 Subject: [PATCH 1/8] Migrate Excel file generation from jxl to apache poi --- morf-excel/pom.xml | 9 +- .../morf/excel/AdditionalSchemaData.java | 0 .../DefaultAdditionalSchemaDataImpl.java | 3 +- .../excel/SpreadsheetDataSetConsumer.java | 236 ++--- .../excel/SpreadsheetDataSetProducer.java | 773 +++++---------- .../morf/excel/TableOutputter.java | 907 ++++++------------ .../org/alfasoftware/morf/excel/package.html | 0 .../excel/TestSpreadsheetDataSetConsumer.java | 96 +- .../excel/TestSpreadsheetDataSetProducer.java | 0 .../morf/excel/TestTableOutputter.java | 260 ++--- .../test/resources/commons-logging.properties | 3 - .../src/test/resources/log4j.properties | 9 - .../excel/TestSpreadsheetDataSetProducer.xls | Bin 240128 -> 0 bytes pom.xml | 42 +- 14 files changed, 810 insertions(+), 1528 deletions(-) mode change 100755 => 100644 morf-excel/pom.xml mode change 100755 => 100644 morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java mode change 100755 => 100644 morf-excel/src/main/java/org/alfasoftware/morf/excel/DefaultAdditionalSchemaDataImpl.java mode change 100755 => 100644 morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java mode change 100755 => 100644 morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java mode change 100755 => 100644 morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java mode change 100755 => 100644 morf-excel/src/main/resources/org/alfasoftware/morf/excel/package.html mode change 100755 => 100644 morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java mode change 100755 => 100644 morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java delete mode 100644 morf-excel/src/test/resources/commons-logging.properties delete mode 100644 morf-excel/src/test/resources/log4j.properties delete mode 100755 morf-excel/src/test/resources/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.xls diff --git a/morf-excel/pom.xml b/morf-excel/pom.xml old mode 100755 new mode 100644 index 93e353315..96fd223a1 --- a/morf-excel/pom.xml +++ b/morf-excel/pom.xml @@ -40,8 +40,13 @@ morf-core - net.sourceforge.jexcelapi - jxl + org.apache.poi + poi + + + + org.apache.poi + poi-ooxml org.alfasoftware diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java old mode 100755 new mode 100644 diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/DefaultAdditionalSchemaDataImpl.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/DefaultAdditionalSchemaDataImpl.java old mode 100755 new mode 100644 index 577ed68e2..5d36be1d6 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/DefaultAdditionalSchemaDataImpl.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/DefaultAdditionalSchemaDataImpl.java @@ -15,9 +15,8 @@ package org.alfasoftware.morf.excel; -import org.apache.commons.lang3.StringUtils; - import org.alfasoftware.morf.metadata.Table; +import org.apache.commons.lang3.StringUtils; /** * A default implementation of {@link AdditionalSchemaData} which provides blank diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java old mode 100755 new mode 100644 index 67f200297..7ac67003d --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java @@ -28,17 +28,16 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; - -import jxl.Workbook; -import jxl.format.Alignment; -import jxl.format.Colour; -import jxl.format.UnderlineStyle; -import jxl.write.Label; -import jxl.write.WritableCellFormat; -import jxl.write.WritableFont; -import jxl.write.WritableHyperlink; -import jxl.write.WritableSheet; -import jxl.write.WritableWorkbook; +import org.apache.poi.common.usermodel.HyperlinkType; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.WorkbookUtil; /** * Consumes data sets and outputs spreadsheets. @@ -49,214 +48,151 @@ public class SpreadsheetDataSetConsumer implements DataSetConsumer { private static final Log log = LogFactory.getLog(SpreadsheetDataSetConsumer.class); - /** - * - * The number of rows in the title. - */ private static final int NUMBER_OF_ROWS_IN_TITLE = 2; - - /** - * The number of rows to include in the sample section if not specified. - */ private static final int DEFAULT_SAMPLE_ROWS = 5; - /** - * Stream to which the spreadsheet will be output. - */ private final OutputStream documentOutputStream; - - /** - * The workbook currently under construction. - */ - private WritableWorkbook workbook; - - /** - * Number of rows to output for each table. - */ + private Workbook workbook; private final Optional> rowsPerTable; - - - /** - * Outputter for putting tables into Excel. - */ private final TableOutputter tableOutputter; - - /** - * @param documentOutputStream Stream to which the spreadsheet file should be written. - */ public SpreadsheetDataSetConsumer(OutputStream documentOutputStream) { this(documentOutputStream, Optional.>empty()); } - - /** - * @param documentOutputStream Stream to which the spreadsheet file should be written. - * @param rowsPerTable stream The list of tables to export along with the number of rows per table to export. If not supplied, - * all tables are exported to the limit of rows in Excel. - */ - public SpreadsheetDataSetConsumer( - OutputStream documentOutputStream, - Optional> rowsPerTable) { + public SpreadsheetDataSetConsumer(OutputStream documentOutputStream, Optional> rowsPerTable) { this(documentOutputStream, rowsPerTable, new DefaultAdditionalSchemaDataImpl()); } - - /** - * @param documentOutputStream Stream to which the spreadsheet file should be written. - * @param rowsPerTable stream The list of tables to export along with the number of rows per table to export. If not supplied, - * all tables are exported to the limit of rows in Excel. - * @param additionalSchemaData the source of additional metadata not available from the database schema. - */ - public SpreadsheetDataSetConsumer( - OutputStream documentOutputStream, - Optional> rowsPerTable, + public SpreadsheetDataSetConsumer(OutputStream documentOutputStream, Optional> rowsPerTable, AdditionalSchemaData additionalSchemaData) { this(documentOutputStream, rowsPerTable, new TableOutputter(additionalSchemaData)); } - - /** - * Package private constructor, for testing purposes (isolates the {@link TableOutputter} - * dependency). - */ - SpreadsheetDataSetConsumer( - OutputStream documentOutputStream, - Optional> rowsPerTable, + SpreadsheetDataSetConsumer(OutputStream documentOutputStream, Optional> rowsPerTable, TableOutputter tableOutputter) { - super(); this.documentOutputStream = documentOutputStream; this.tableOutputter = tableOutputter; this.rowsPerTable = rowsPerTable; } - - /** - * @see org.alfasoftware.morf.dataset.DataSetConsumer#open() - */ @Override public void open() { - try { - workbook = Workbook.createWorkbook(documentOutputStream); - } catch (IOException e) { - throw new RuntimeException("Error creating writable workbook", e); - } + workbook = new HSSFWorkbook(); } - - /** - * Create the index worksheet. - * - *

This also creates links back to the index in each of the worksheets.

- */ public void createIndex() { - WritableSheet sheet = workbook.createSheet(spreadsheetifyName("Index"), 0); + Sheet sheet = workbook.createSheet(safeSheetName(spreadsheetifyName("Index"))); + workbook.setSheetOrder(sheet.getSheetName(), 0); createTitle(sheet, "Index"); try { - // Create links for each worksheet, apart from the first sheet which is the - // index we're currently creating - final String[] names = workbook.getSheetNames(); - for (int currentSheet = 1; currentSheet < names.length; currentSheet++) { - // Create the link from the index to the table's worksheet - WritableHyperlink link = new WritableHyperlink(0, currentSheet - 1 + NUMBER_OF_ROWS_IN_TITLE, names[currentSheet], workbook.getSheet(currentSheet), 0, 0); - sheet.addHyperlink(link); - - //Add the filename in column B (stored in cell B2 of each sheet) - String fileName = workbook.getSheet(currentSheet).getCell(1, 1).getContents(); - Label fileNameLabel = new Label(1, currentSheet - 1 + NUMBER_OF_ROWS_IN_TITLE, fileName); - WritableFont fileNameFont = new WritableFont(WritableFont.ARIAL,10,WritableFont.NO_BOLD,false,UnderlineStyle.NO_UNDERLINE,Colour.BLACK); - WritableCellFormat fileNameFormat = new WritableCellFormat(fileNameFont); - fileNameLabel.setCellFormat(fileNameFormat); - sheet.addCell(fileNameLabel); - - // Create the link back to the index - link = new WritableHyperlink(0, 1, "Back to index", sheet, 0, currentSheet + NUMBER_OF_ROWS_IN_TITLE - 1); - workbook.getSheet(currentSheet).addHyperlink(link); - //Set column A of each sheet to be wide enough to show "Back to index" - workbook.getSheet(currentSheet).setColumnView(0, 13); + for (int currentSheet = 1; currentSheet < workbook.getNumberOfSheets(); currentSheet++) { + Sheet tableSheet = workbook.getSheetAt(currentSheet); + int rowIndex = currentSheet - 1 + NUMBER_OF_ROWS_IN_TITLE; + + Row row = getOrCreateRow(sheet, rowIndex); + Cell linkCell = row.createCell(0); + linkCell.setCellValue(tableSheet.getSheetName()); + linkCell.setHyperlink(workbook.getCreationHelper().createHyperlink(HyperlinkType.DOCUMENT)); + linkCell.getHyperlink().setAddress("'" + tableSheet.getSheetName() + "'!A1"); + + String fileName = getCellString(tableSheet, 1, 1); + Cell fileNameCell = row.createCell(1); + fileNameCell.setCellValue(fileName); + fileNameCell.setCellStyle(indexFileNameStyle()); + + Row backLinkRow = getOrCreateRow(tableSheet, 1); + Cell backLinkCell = backLinkRow.createCell(0); + backLinkCell.setCellValue("Back to index"); + backLinkCell.setHyperlink(workbook.getCreationHelper().createHyperlink(HyperlinkType.DOCUMENT)); + backLinkCell.getHyperlink().setAddress("'" + sheet.getSheetName() + "'!A" + (rowIndex + 1)); + tableSheet.setColumnWidth(0, 13 * 256); } - // Make Column A fairly wide to show tab names and hide column B - sheet.setColumnView(0, 35); - sheet.setColumnView(1, 0); - + sheet.setColumnWidth(0, 35 * 256); + sheet.setColumnWidth(1, 1); } catch (Exception e) { throw new RuntimeException(e); } } + private CellStyle indexFileNameStyle() { + CellStyle style = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setColor(Font.COLOR_NORMAL); + style.setFont(font); + return style; + } - /** - * Converts camel capped names to something we can show in a spreadsheet. - * - * @param name Name to convert. - * @return A human readable version of the name wtih camel caps replaced by spaces. - */ private String spreadsheetifyName(String name) { return StringUtils.capitalize(name).replaceAll("([A-Z])", " $1").trim(); } - - /** - * Inserts a row at the top of the sheet with the given title - * @param sheet add the title to - * @param title to add - */ - protected void createTitle(WritableSheet sheet, String title) { + protected void createTitle(Sheet sheet, String title) { try { - Label cell = new Label(0, 0, title); - WritableFont headingFont = new WritableFont(WritableFont.ARIAL, 16, WritableFont.BOLD); - WritableCellFormat headingFormat = new WritableCellFormat(headingFont); - cell.setCellFormat(headingFormat); - sheet.addCell(cell); - - cell = new Label(12, 0, "Copyright " + new SimpleDateFormat("yyyy").format(new Date()) + " Alfa Financial Software Ltd."); - WritableCellFormat copyrightFormat = new WritableCellFormat(); - copyrightFormat.setAlignment(Alignment.RIGHT); - cell.setCellFormat(copyrightFormat); - sheet.addCell(cell); - + Cell cell = getOrCreateRow(sheet, 0).createCell(0); + cell.setCellValue(title); + CellStyle headingStyle = workbook.createCellStyle(); + Font headingFont = workbook.createFont(); + headingFont.setBold(true); + headingFont.setFontHeightInPoints((short) 16); + headingStyle.setFont(headingFont); + cell.setCellStyle(headingStyle); + + Cell copyrightCell = getOrCreateRow(sheet, 0).createCell(12); + copyrightCell.setCellValue("Copyright " + new SimpleDateFormat("yyyy").format(new Date()) + " Alfa Financial Software Ltd."); + CellStyle copyrightStyle = workbook.createCellStyle(); + copyrightStyle.setAlignment(HorizontalAlignment.RIGHT); + copyrightCell.setCellStyle(copyrightStyle); } catch (Exception e) { throw new RuntimeException(e); } } - - /** - * @see org.alfasoftware.morf.dataset.DataSetConsumer#close(CloseState) - */ @Override public void close(CloseState closeState) { try { - - // Create the index createIndex(); - - workbook.write(); + workbook.write(documentOutputStream); workbook.close(); - } catch (Exception e) { + } catch (IOException e) { throw new RuntimeException("Error closing writable workbook", e); } } - - /** - * @see org.alfasoftware.morf.dataset.DataSetConsumer#table(org.alfasoftware.morf.metadata.Table, java.lang.Iterable) - */ @Override public void table(Table table, Iterable records) { Integer maxSampleRows = DEFAULT_SAMPLE_ROWS; if (rowsPerTable.isPresent()) { maxSampleRows = rowsPerTable.get().get(table.getName().toUpperCase()); } + if (maxSampleRows == null) { - log.info("File [" + table.getName() + "] excluded in configuration." ); + log.info("File [" + table.getName() + "] excluded in configuration."); } else if (tableOutputter.tableHasUnsupportedColumns(table)) { - log.info("File [" + table.getName() + "] skipped - unsupported columns." ); + log.info("File [" + table.getName() + "] skipped - unsupported columns."); } else { - log.info("File [" + table.getName() + "] generating..." ); + log.info("File [" + table.getName() + "] generating..."); tableOutputter.table(maxSampleRows, workbook, table, records); } } + + private Row getOrCreateRow(Sheet sheet, int rowIndex) { + Row row = sheet.getRow(rowIndex); + return row == null ? sheet.createRow(rowIndex) : row; + } + + private String getCellString(Sheet sheet, int columnIndex, int rowIndex) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + return ""; + } + Cell cell = row.getCell(columnIndex); + return cell == null ? "" : cell.toString(); + } + + private String safeSheetName(String name) { + return WorkbookUtil.createSafeSheetName(name); + } } diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java old mode 100755 new mode 100644 index 6e8cff643..4ae1e1ca0 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java @@ -1,509 +1,264 @@ -/* Copyright 2017 Alfa Financial Software - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.alfasoftware.morf.excel; - -import java.io.InputStream; -import java.text.SimpleDateFormat; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.alfasoftware.morf.dataset.DataSetProducer; -import org.alfasoftware.morf.dataset.Record; -import org.alfasoftware.morf.metadata.DataSetUtils; -import org.alfasoftware.morf.metadata.Schema; -import org.alfasoftware.morf.metadata.Sequence; -import org.alfasoftware.morf.metadata.Table; -import org.alfasoftware.morf.metadata.DataSetUtils.RecordBuilder; -import org.alfasoftware.morf.metadata.View; -import org.apache.commons.lang3.StringUtils; - -import jxl.Cell; -import jxl.Hyperlink; -import jxl.Sheet; -import jxl.Workbook; -import jxl.WorkbookSettings; -import jxl.read.biff.HyperlinkRecord; - -/** - * Converts Excel spreadsheets into a dataset. - * - * @author Copyright (c) Alfa Financial Software 2010 - */ -public class SpreadsheetDataSetProducer implements DataSetProducer { - /** - * Pattern for extracting sheet names from hyperlinks of the form: - * 'Sheet name'!A1:A1 - */ - private static final Pattern sheetName = Pattern.compile("'([^']*)'.*"); - - /** - * Store of tables extracted from the given excel files and their records. - */ - private final Map> tables = new HashMap<>(); - - - /** - * List of translations that have been extracted from the set of Excel files. - */ - private final List translations = new LinkedList<>(); - - - /** - * Prepares the producer with a set of Excel files to produce data from. - * - * @param excelFiles the Excel files to harvest data from - */ - public SpreadsheetDataSetProducer(final InputStream... excelFiles) { - // Open each spreadsheet and parse it - for (InputStream stream : excelFiles) { - parseWorkbook(stream); - } - } - - - /** - * Creates the collection of translation records from a given set of - * translations. - * - * @param id ID of the translation record - * @param translation Translation string to create - * @return the record representing the translation - */ - private Record createTranslationRecord(final int id, final String translation) { - final RecordBuilder record = DataSetUtils.record(); - record.setString("translationText", translation); - final Date now = new Date(); - record.setString("changeDate", new SimpleDateFormat("yyyyMMdd").format(now)); - record.setString("changedTime", new SimpleDateFormat("hhmmss").format(now)); - record.setInteger("localeSequenceNumber", 1); // Assume locale 1 for translations on initial upload - record.setInteger("translationSequenceNumber", id); - record.setInteger("translationId", id); - record.setInteger("id", id); - return record; - } - - - /** - * Gets the hyperlink that starts at the given column/row in the given sheet. - * - * @param sheet sheet to look for a hyperlink in - * @param column column of the hyperlink - * @param row row of the hyperlink - * @return the hyperlink, if found. Otherwise, null - */ - private HyperlinkRecord getHyperlink(Sheet sheet, int column, int row) { - for (Hyperlink link : sheet.getHyperlinks()) { - if (link.getColumn() == column && link.getRow() == row) { - return (HyperlinkRecord)link; - } - } - - return null; - } - - - /** - * Parse the workbook from an {@link InputStream}. - * - * @param inputStream InputStream from the spreadsheet - */ - private void parseWorkbook(final InputStream inputStream) { - Workbook workbook = null; - try { - final WorkbookSettings settings = new WorkbookSettings(); - settings.setEncoding("CP1252"); - workbook = Workbook.getWorkbook(inputStream, settings); - - /* - * The first sheet in the workbook is the index sheet. It contains links - * to sheets containing data as well as the table name to use. - */ - final Sheet sheet = workbook.getSheet(0); - final int column = 1; - for (int row = 2; row < sheet.getRows(); row++) { - final Cell cell = sheet.getCell(column, row); - if (StringUtils.isEmpty(cell.getContents())) { - break; - } - - final HyperlinkRecord hyperlink = getHyperlink(sheet, cell.getColumn() - 1, cell.getRow()); - final String worksheetName = getDestinationWorksheet(hyperlink); - if (workbook.getSheet(worksheetName) == null) { - throw new IllegalStateException("Failed to find worksheet with name [" + worksheetName + "]"); - } - final List records = getRecords(workbook.getSheet(worksheetName)); - this.tables.put(cell.getContents(), records); - } - } catch (Exception e) { - throw new RuntimeException("Failed to parse spreadsheet", e); - } finally { - if (workbook != null) { - workbook.close(); - } - } - } - - - /** - * Gets the name of the destination worksheet for the given hyperlink. - * - * @param hyperlink Hyperlink to determine worksheet name for - * @return the name of the worksheet that the hyperlink points to - */ - private String getDestinationWorksheet(HyperlinkRecord hyperlink) { - /* - * Hyperlinks will be either to a specific cell or to a worksheet as a - * whole. If the regular expression for the sheet name part of a link - * doesn't match then the hyperlink must be to a worksheet as a whole. - */ - final Matcher matcher = sheetName.matcher(hyperlink.getLocation()); - if (matcher.matches()) { - return matcher.group(1); - } else { - return hyperlink.getLocation(); - } - } - - - /** - * Finds the heading row in a given worksheet. - * - *

This works by assuming that the table data starts in the row - * immediately following a row containing hyperlinks that is after - * a row that starts with "Parameters to Set Up". E.g.

- * - *
-   * Parameters to Setup |                  |
-   * ----------------------------------------
-   * Random comments     | More comments    |
-   * ----------------------------------------
-   * Column Heading 1    | Column Heading 2 | <-- These are hyperlinks
-   * 
- * - * @param sheet worksheet to search for data - * @return the index of the starting row for the data - */ - private int findHeaderRow(final Sheet sheet) { - int row = 0; - - // Find the start row - for (; row < sheet.getRows(); row++) { - if ("Parameters to Set Up".equalsIgnoreCase(sheet.getCell(0, row).getContents())) { - // Skip this row - row++; - break; - } - } - - // The heading row contains hyperlinks so continue scanning down until a - // hyperlink is found - for (; row < sheet.getRows(); row++) { - final HyperlinkRecord hyperlink = getHyperlink(sheet, 0, row); - if (hyperlink != null) { - return row; - } - } - - // Either the parameters to set up row wasn't found or no hyperlinks were found - throw new IllegalStateException("Could not find header row in worksheet [" + sheet.getName() + "]"); - } - - - /** - * Determines if a worksheet contains something that looks like translations. - * This is done by looking for a gap in the column headings followed by an - * actual heading, e.g. - * - *
-   * Heading 1 | Heading 2 |         | Translation
-   * -------------------------------------------------
-   * Value 1   | Value 2   |         | Bonjour
-   * 
- * - * @param sheet sheet to look for translations in - * @param headingRow the index of the heading row - * @return the index of the translation column, -1 otherwise - */ - private int getTranslationsColumnIndex(Sheet sheet, int headingRow) { - boolean hasBlank = false; - int i = 0; - for (; i < sheet.getRow(headingRow).length; i++) { - if (sheet.getCell(i, headingRow).getContents().length() == 0) { - hasBlank = true; - break; - } - } - if (!hasBlank) { - return -1; - } - for (; i < sheet.getRow(headingRow).length; i++) { - if (sheet.getCell(i, headingRow).getContents().length() > 0) { - return i; - } - } - return -1; - } - - - /** - * Counts the number of headings in the given sheet. This excludes any - * heading related to translations. - * - * @param sheet Worksheet to count headings in - * @param headingRowIndex Index of the heading row - * @return the number of headings - */ - private int countHeadings(final Sheet sheet, final int headingRowIndex) { - for (int i = 0; i < sheet.getRow(headingRowIndex).length; i++) { - // A blank heading is the start of additional headings such as the - // translation heading - if (sheet.getCell(i, headingRowIndex).getContents().length() == 0) { - return i; - } - } - return sheet.getRow(headingRowIndex).length; - } - - - /** - * Get all the records from the given Excel sheet. - * - * @param sheet worksheet to get records from - * @return the extracted records - */ - private List getRecords(Sheet sheet) { - try { - long id = 1; - int row = findHeaderRow(sheet); - - // Get the column headings - final Map columnHeadingsMap = new HashMap<>(); - for (int i = 0; i < countHeadings(sheet, row); i++) { - columnHeadingsMap.put(columnName(sheet.getCell(i, row).getContents()), i); - } - - // Does this sheet have translations or not? - final int translationColumn = getTranslationsColumnIndex(sheet, row); - - // -- Now get the data... - // - row++; // The data is always one row below the headings - List records = new LinkedList<>(); - for (; row < sheet.getRows(); row++) { - final Cell[] cells = sheet.getRow(row); - - // If all the cells are blank then this is the end of the table - if (allBlank(cells)) { - break; - } - - records.add(createRecord(id++, columnHeadingsMap, translationColumn, cells)); - } - - return records; - } catch (Exception e) { - throw new RuntimeException("Failed to parse worksheet [" + sheet.getName() + "]", e); - } - } - - - /** - * Determines if the given cells are all blank or not. - * @param cells to check if they are blank or not - * @return true if all the cells are blank, otherwise false. - */ - private boolean allBlank(final Cell... cells) { - for (Cell cell : cells) { - if (cell.getContents().length() != 0) { - return false; - } - } - return true; - } - - - /** - * Creates a record from a set of cells from a worksheet. - * - * @param id ID of the row - * @param columnHeadingsMap Map of column headings to their index - * @param translationColumn Column containing translations - * @param cells The cells to process - * @return the created record - */ - private Record createRecord(final long id, final Map columnHeadingsMap, final int translationColumn, final Cell... cells) { - final int translationId; - if (translationColumn != -1 && cells[translationColumn].getContents().length() > 0) { - translationId = translations.size() + 1; - translations.add(createTranslationRecord(translationId, cells[translationColumn].getContents())); - } else { - translationId = 0; - } - - final RecordBuilder record = DataSetUtils.record(); - for (Entry column : columnHeadingsMap.entrySet()) { - if (column.getValue() < cells.length) { - record.setString(column.getKey(), cells[column.getValue()].getContents()); - } else { - // If the cell is actually specified then assume it is default blank - record.setString(column.getKey(), ""); - } - } - record.setLong("id", id); - record.setInteger("translationId", translationId); - return record; - } - - - /** - * Converts the given long name in to a column name. This is the same as - * removing all the spaces and making the first character lowercase. - * - * @param longName the long name to convert - * @return the name of the column - */ - private String columnName(final String longName) { - final String noSpaces = longName.replaceAll(" ", ""); - return noSpaces.substring(0, 1).toLowerCase() + noSpaces.substring(1); - } - - - /** - * {@inheritDoc} - * - * @see org.alfasoftware.morf.dataset.DataSetProducer#getSchema() - */ - @Override - public Schema getSchema() { - return new Schema() { - - @Override - public Table getTable(String name) { - throw new UnsupportedOperationException("Cannot get the metadata of a table for a spreadsheet"); - } - - @Override - public boolean isEmptyDatabase() { - return tables.isEmpty(); - } - - @Override - public boolean tableExists(String name) { - return tables.containsKey(name); - } - - @Override - public Collection tableNames() { - return tables.keySet(); - } - - @Override - public Collection tables() { - throw new UnsupportedOperationException("Cannot get the metadata of a table for a spreadsheet"); - } - - @Override - public boolean viewExists(String name) { - return false; - } - - @Override - public View getView(String name) { - throw new IllegalArgumentException("Invalid view [" + name + "]. Views are not supported in spreadsheets"); - } - - @Override - public Collection viewNames() { - return Collections.emptySet(); - } - - @Override - public Collection views() { - return Collections.emptySet(); - } - - @Override - public boolean sequenceExists(String name) { - return false; - } - - @Override - public Sequence getSequence(String name) { - throw new IllegalArgumentException("Invalid sequence [" + name + "]. Sequences are not supported in spreadsheets"); - } - - @Override - public Collection sequenceNames() { - return Collections.emptySet(); - } - - @Override - public Collection sequences() { - return Collections.emptySet(); - } - }; - } - - - /** - * {@inheritDoc} - * - * @see org.alfasoftware.morf.dataset.DataSetProducer#open() - */ - @Override - public void open() { - // Nothing to do - } - - - /** - * {@inheritDoc} - * - * @see org.alfasoftware.morf.dataset.DataSetProducer#close() - */ - @Override - public void close() { - // Nothing to do - } - - - /** - * {@inheritDoc} - * - * @see org.alfasoftware.morf.dataset.DataSetProducer#records(java.lang.String) - */ - @Override - public Iterable records(String tableName) { - return tables.get(tableName); - } - - - /** - * @see org.alfasoftware.morf.dataset.DataSetProducer#isTableEmpty(java.lang.String) - */ - @Override - public boolean isTableEmpty(String tableName) { - return tables.get(tableName).isEmpty(); - } -} +/* Copyright 2017 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.excel; + +import java.io.InputStream; +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.alfasoftware.morf.dataset.DataSetProducer; +import org.alfasoftware.morf.dataset.Record; +import org.alfasoftware.morf.metadata.DataSetUtils; +import org.alfasoftware.morf.metadata.DataSetUtils.RecordBuilder; +import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.Sequence; +import org.alfasoftware.morf.metadata.Table; +import org.alfasoftware.morf.metadata.View; +import org.apache.commons.lang3.StringUtils; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; + +/** + * Converts Excel spreadsheets into a dataset. + */ +public class SpreadsheetDataSetProducer implements DataSetProducer { + + private static final Pattern SHEET_NAME = Pattern.compile("'([^']*)'.*"); + + private final Map> tables = new HashMap<>(); + private final List translations = new LinkedList<>(); + private final DataFormatter dataFormatter = new DataFormatter(); + + public SpreadsheetDataSetProducer(final InputStream... excelFiles) { + for (InputStream stream : excelFiles) { + parseWorkbook(stream); + } + } + + private Record createTranslationRecord(final int id, final String translation) { + final RecordBuilder record = DataSetUtils.record(); + record.setString("translationText", translation); + final Date now = new Date(); + record.setString("changeDate", new SimpleDateFormat("yyyyMMdd").format(now)); + record.setString("changedTime", new SimpleDateFormat("hhmmss").format(now)); + record.setInteger("localeSequenceNumber", 1); + record.setInteger("translationSequenceNumber", id); + record.setInteger("translationId", id); + record.setInteger("id", id); + return record; + } + + private void parseWorkbook(final InputStream inputStream) { + try (Workbook workbook = WorkbookFactory.create(inputStream)) { + final Sheet sheet = workbook.getSheetAt(0); + final int column = 1; + for (int row = 2; row <= sheet.getLastRowNum(); row++) { + String tableName = getCellContents(sheet, column, row); + if (StringUtils.isEmpty(tableName)) { + break; + } + + String worksheetName = getDestinationWorksheet(sheet, column - 1, row); + Sheet worksheet = workbook.getSheet(worksheetName); + if (worksheet == null) { + throw new IllegalStateException("Failed to find worksheet with name [" + worksheetName + "]"); + } + this.tables.put(tableName, getRecords(worksheet)); + } + } catch (Exception e) { + throw new RuntimeException("Failed to parse spreadsheet", e); + } + } + + private String getDestinationWorksheet(Sheet sheet, int column, int row) { + Cell cell = getCell(sheet, column, row); + if (cell == null || cell.getHyperlink() == null || cell.getHyperlink().getAddress() == null) { + throw new IllegalStateException("Failed to find hyperlink in index sheet at [" + column + "," + row + "]"); + } + + String address = cell.getHyperlink().getAddress(); + Matcher matcher = SHEET_NAME.matcher(address); + if (matcher.matches()) { + return matcher.group(1); + } + int separator = address.indexOf('!'); + return separator >= 0 ? address.substring(0, separator) : address; + } + + private int findHeaderRow(final Sheet sheet) { + int row = 0; + for (; row <= sheet.getLastRowNum(); row++) { + if ("Parameters to Set Up".equalsIgnoreCase(getCellContents(sheet, 0, row))) { + row++; + break; + } + } + + for (; row <= sheet.getLastRowNum(); row++) { + Cell cell = getCell(sheet, 0, row); + if (cell != null && cell.getHyperlink() != null) { + return row; + } + } + + throw new IllegalStateException("Could not find header row in worksheet [" + sheet.getSheetName() + "]"); + } + + private int getTranslationsColumnIndex(Sheet sheet, int headingRow) { + boolean hasBlank = false; + int width = getRowWidth(sheet, headingRow); + int i = 0; + for (; i < width; i++) { + if (getCellContents(sheet, i, headingRow).isEmpty()) { + hasBlank = true; + break; + } + } + if (!hasBlank) { + return -1; + } + for (; i < width; i++) { + if (!getCellContents(sheet, i, headingRow).isEmpty()) { + return i; + } + } + return -1; + } + + private int countHeadings(final Sheet sheet, final int headingRowIndex) { + int width = getRowWidth(sheet, headingRowIndex); + for (int i = 0; i < width; i++) { + if (getCellContents(sheet, i, headingRowIndex).isEmpty()) { + return i; + } + } + return width; + } + + private List getRecords(Sheet sheet) { + try { + long id = 1; + int row = findHeaderRow(sheet); + final Map columnHeadingsMap = new HashMap<>(); + for (int i = 0; i < countHeadings(sheet, row); i++) { + columnHeadingsMap.put(columnName(getCellContents(sheet, i, row)), i); + } + + final int translationColumn = getTranslationsColumnIndex(sheet, row); + row++; + List records = new LinkedList<>(); + for (; row <= sheet.getLastRowNum(); row++) { + if (allBlank(sheet, row)) { + break; + } + records.add(createRecord(id++, columnHeadingsMap, translationColumn, sheet, row)); + } + return records; + } catch (Exception e) { + throw new RuntimeException("Failed to parse worksheet [" + sheet.getSheetName() + "]", e); + } + } + + private boolean allBlank(final Sheet sheet, int rowIndex) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + return true; + } + for (int i = row.getFirstCellNum() < 0 ? 0 : row.getFirstCellNum(); i < row.getLastCellNum(); i++) { + if (!getCellContents(sheet, i, rowIndex).isEmpty()) { + return false; + } + } + return true; + } + + private Record createRecord(final long id, final Map columnHeadingsMap, final int translationColumn, + final Sheet sheet, final int rowIndex) { + final int translationId; + String translationValue = translationColumn == -1 ? "" : getCellContents(sheet, translationColumn, rowIndex); + if (translationColumn != -1 && translationValue.length() > 0) { + translationId = translations.size() + 1; + translations.add(createTranslationRecord(translationId, translationValue)); + } else { + translationId = 0; + } + + final RecordBuilder record = DataSetUtils.record(); + for (Entry column : columnHeadingsMap.entrySet()) { + record.setString(column.getKey(), getCellContents(sheet, column.getValue(), rowIndex)); + } + record.setLong("id", id); + record.setInteger("translationId", translationId); + return record; + } + + private String columnName(final String longName) { + final String noSpaces = longName.replaceAll(" ", ""); + return noSpaces.substring(0, 1).toLowerCase() + noSpaces.substring(1); + } + + @Override + public Schema getSchema() { + return new Schema() { + @Override public Table getTable(String name) { throw new UnsupportedOperationException("Cannot get the metadata of a table for a spreadsheet"); } + @Override public boolean isEmptyDatabase() { return tables.isEmpty(); } + @Override public boolean tableExists(String name) { return tables.containsKey(name); } + @Override public Collection tableNames() { return tables.keySet(); } + @Override public Collection
tables() { throw new UnsupportedOperationException("Cannot get the metadata of a table for a spreadsheet"); } + @Override public boolean viewExists(String name) { return false; } + @Override public View getView(String name) { throw new IllegalArgumentException("Invalid view [" + name + "]. Views are not supported in spreadsheets"); } + @Override public Collection viewNames() { return Collections.emptySet(); } + @Override public Collection views() { return Collections.emptySet(); } + @Override public boolean sequenceExists(String name) { return false; } + @Override public Sequence getSequence(String name) { throw new IllegalArgumentException("Invalid sequence [" + name + "]. Sequences are not supported in spreadsheets"); } + @Override public Collection sequenceNames() { return Collections.emptySet(); } + @Override public Collection sequences() { return Collections.emptySet(); } + }; + } + + @Override public void open() { } + @Override public void close() { } + @Override public Iterable records(String tableName) { return tables.get(tableName); } + @Override public boolean isTableEmpty(String tableName) { return tables.get(tableName).isEmpty(); } + + private int getRowWidth(Sheet sheet, int rowIndex) { + Row row = sheet.getRow(rowIndex); + return row == null || row.getLastCellNum() < 0 ? 0 : row.getLastCellNum(); + } + + private Cell getCell(Sheet sheet, int columnIndex, int rowIndex) { + Row row = sheet.getRow(rowIndex); + return row == null ? null : row.getCell(columnIndex); + } + + private String getCellContents(Sheet sheet, int columnIndex, int rowIndex) { + Cell cell = getCell(sheet, columnIndex, rowIndex); + return cell == null ? "" : dataFormatter.formatCellValue(cell); + } +} diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java old mode 100755 new mode 100644 index cd51588aa..f42612eb6 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java @@ -1,591 +1,316 @@ -/* Copyright 2017 Alfa Financial Software - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.alfasoftware.morf.excel; - -import static org.alfasoftware.morf.metadata.DataType.BIG_INTEGER; -import static org.alfasoftware.morf.metadata.DataType.CLOB; -import static org.alfasoftware.morf.metadata.DataType.DECIMAL; -import static org.alfasoftware.morf.metadata.DataType.INTEGER; -import static org.alfasoftware.morf.metadata.DataType.STRING; - -import java.math.BigDecimal; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - -import org.alfasoftware.morf.dataset.Record; -import org.alfasoftware.morf.metadata.Column; -import org.alfasoftware.morf.metadata.DataType; -import org.alfasoftware.morf.metadata.Table; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -import com.google.common.base.Predicate; -import com.google.common.collect.Iterables; -import com.google.common.collect.Sets; -import jxl.Cell; -import jxl.format.Alignment; -import jxl.format.Border; -import jxl.format.BorderLineStyle; -import jxl.format.Colour; -import jxl.format.UnderlineStyle; -import jxl.format.VerticalAlignment; -import jxl.write.Label; -import jxl.write.WritableCell; -import jxl.write.WritableCellFormat; -import jxl.write.WritableFont; -import jxl.write.WritableHyperlink; -import jxl.write.WritableSheet; -import jxl.write.WritableWorkbook; -import jxl.write.WriteException; - -/** - * Outputs tables to an excel spreadsheet. - * - * @author Copyright (c) Alfa Financial Software 2010 - */ -class TableOutputter { - - private static final Log log = LogFactory.getLog(TableOutputter.class); - - /** - * The number of rows in the title. - */ - private static final int NUMBER_OF_ROWS_IN_TITLE = 2; - - /** - * The maximum number of rows supported in an XLS. - */ - private static final int MAX_EXCEL_ROWS = 65536; - - /** - * The maximum number of rows supported in an XLS. - */ - private static final int MAX_EXCEL_COLUMNS = 256; - - /** - * The maximum number of characters supported in an XLS cell. - */ - private static final int MAX_CELL_CHARACTERS = 32767; - - /** - * The data types we can output to a spreadsheet. - */ - private static final Set supportedDataTypes = Sets.immutableEnumSet(STRING, DECIMAL, BIG_INTEGER, INTEGER, CLOB); - - /** - * A source of non-schema related data. - */ - private final AdditionalSchemaData additionalSchemaData; - - - /** - * Constructor. - * - * @param additionalSchemaData A source of non-schema related data. - */ - public TableOutputter(AdditionalSchemaData additionalSchemaData) { - this.additionalSchemaData = additionalSchemaData; - } - - - /** - * Output the given table to the given workbook. - * - * @param maxSampleRows the maximum number of rows to export in the "sample data" section - * (all rows are included in the "Parameters to set up" section). - * @param workbook to add the table to. - * @param table to add to the workbook. - * @param records of data to output. - */ - public void table(int maxSampleRows, final WritableWorkbook workbook, final Table table, final Iterable records) { - final WritableSheet workSheet = workbook.createSheet(spreadsheetifyName(table.getName()), workbook.getNumberOfSheets()); - - boolean columnsTruncated = table.columns().size() > MAX_EXCEL_COLUMNS; - if(columnsTruncated) { - log.warn("Output for table '" + table.getName() + "' exceeds the maximum number of columns (" + MAX_EXCEL_COLUMNS + ") in an Excel worksheet. It will be truncated."); - } - - boolean rowsTruncated = false; - - try { - - int currentRow = NUMBER_OF_ROWS_IN_TITLE + 1; - - try { - - final Map helpTextRowNumbers = new HashMap<>(); - - //Now output.... - //Help text - currentRow = outputHelp(workSheet, table, currentRow, helpTextRowNumbers); - - //"Example Data" - Label exampleLabel = new Label(0, currentRow, "Example Data"); - exampleLabel.setCellFormat(getBoldFormat()); - workSheet.addCell(exampleLabel); - currentRow++; - - //Headings for example data - currentRow = outputDataHeadings(workSheet, table, currentRow, helpTextRowNumbers); - - //Actual example data - currentRow = outputExampleData(maxSampleRows, workSheet, table, currentRow, records); - - //"Parameters to Set Up" - Label dataLabel = new Label(0, currentRow, "Parameters to Set Up"); - dataLabel.setCellFormat(getBoldFormat()); - workSheet.addCell(dataLabel); - currentRow++; - - //Headings for parameters to be uploaded - currentRow = outputDataHeadings(workSheet, table, currentRow, helpTextRowNumbers); - currentRow = outputExampleData(null, workSheet, table, currentRow, records); - } catch (RowLimitExceededException e) { - log.warn(e.getMessage()); - rowsTruncated = true; - } - } - catch (Exception e) { - throw new RuntimeException("Error outputting table '" + table.getName() + "'", e); - } - - /* - * Write the title for the worksheet - adding truncation information if appropriate - */ - if(columnsTruncated || rowsTruncated) { - StringBuilder truncatedSuffix = new StringBuilder(); - truncatedSuffix.append(" ["); - - if(columnsTruncated) { - truncatedSuffix.append("COLUMNS"); - } - - if(columnsTruncated && rowsTruncated) { - truncatedSuffix.append(" & "); - } - - if(rowsTruncated) { - truncatedSuffix.append("ROWS"); - } - - truncatedSuffix.append(" TRUNCATED]"); - - createTitle(workSheet, workSheet.getName() + truncatedSuffix.toString(), table.getName()); - } - else { - createTitle(workSheet, workSheet.getName(), table.getName()); - } - } - - - /** - * Converts camel capped names to something we can show in a spreadsheet. - * - * @param name Name to convert. - * @return A human readable version of the name wtih camel caps replaced by spaces. - */ - private String spreadsheetifyName(String name) { - return StringUtils.capitalize(name).replaceAll("([A-Z][a-z])", " $1").trim(); - } - - - /** - * Inserts a row at the top of the sheet with the given title - * @param sheet to add the title to - * @param title to add - * @param fileName of the ALFA file to which the sheet relates - */ - private void createTitle(WritableSheet sheet, String title, String fileName) { - try { - //Friendly file name in A1 - Label cell = new Label(0, 0, title); - WritableFont headingFont = new WritableFont(WritableFont.ARIAL, 16, WritableFont.BOLD); - WritableCellFormat headingFormat = new WritableCellFormat(headingFont); - cell.setCellFormat(headingFormat); - sheet.addCell(cell); - - //ALFA file name in B2 (hidden in white) - cell = new Label(1, 1, fileName); - WritableFont fileNameFont = new WritableFont(WritableFont.ARIAL,10,WritableFont.NO_BOLD,false,UnderlineStyle.NO_UNDERLINE,Colour.WHITE); - WritableCellFormat fileNameFormat = new WritableCellFormat(fileNameFont); - cell.setCellFormat(fileNameFormat); - sheet.addCell(cell); - - //Copyright notice in M1 - cell = new Label(12, 0, "Copyright " + new SimpleDateFormat("yyyy").format(new Date()) + " Alfa Financial Software Ltd."); - WritableCellFormat copyrightFormat = new WritableCellFormat(); - copyrightFormat.setAlignment(Alignment.RIGHT); - cell.setCellFormat(copyrightFormat); - sheet.addCell(cell); - - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - - /** - * @return the standard font to use - */ - private WritableFont getStandardFont() { - return new WritableFont(WritableFont.ARIAL, 8); - } - - - /** - * @return the format to use for normal cells - * @throws WriteException if the format could not be created - */ - private WritableCellFormat getStandardFormat() throws WriteException { - WritableCellFormat standardFormat = new WritableCellFormat(getStandardFont()); - standardFormat.setVerticalAlignment(VerticalAlignment.TOP); - return standardFormat; - } - - - /** - * @return the format to use for bold cells - * @throws WriteException if the format could not be created - */ - private WritableCellFormat getBoldFormat() throws WriteException { - WritableFont boldFont = new WritableFont(WritableFont.ARIAL, 8, WritableFont.BOLD); - WritableCellFormat boldHeading = new WritableCellFormat(boldFont); - boldHeading.setBorder(Border.BOTTOM, BorderLineStyle.MEDIUM); - boldHeading.setVerticalAlignment(VerticalAlignment.CENTRE); - boldHeading.setBackground(Colour.GRAY_25); - - - WritableCellFormat boldFormat = new WritableCellFormat(boldFont); - boldFormat.setVerticalAlignment(VerticalAlignment.TOP); - return boldFormat; - } - - - /** - * Outputs the example data rows. - * - * @param numberOfExamples to output - * @param workSheet to add the data rows to - * @param table to get metadata from - * @param startRow to start adding the example rows at - * @param records to add as examples - * @return the new row to carry on outputting at - * @throws WriteException if any of the writes to workSheet fail - */ - private int outputExampleData(final Integer numberOfExamples, WritableSheet workSheet, Table table, final int startRow, Iterable records) throws WriteException { - int currentRow = startRow; - - int rowsOutput = 0; - for (Record record : records) { - - if (currentRow >= MAX_EXCEL_ROWS) { - continue; - } - - if (numberOfExamples != null && rowsOutput >= numberOfExamples) { - // Need to continue the loop rather than break as we need to close - // the connection which happens at the end of iteration... - continue; - } - - record(currentRow, workSheet, table, record); - rowsOutput++; - currentRow++; - } - - if (currentRow >= MAX_EXCEL_ROWS) { - // This is a fix for WEB-56074. It will be removed if/when WEB-42351 is developed. - throw new RowLimitExceededException("Output for table '" + table.getName() + "' exceeds the maximum number of rows (" + MAX_EXCEL_ROWS + ") in an Excel worksheet. It will be truncated."); - } - - currentRow++; - return currentRow; - } - - - /** - * @param workSheet to add the help to - * @param table to fetch metadata from - * @param startRow to start adding rows at - * @param helpTextRowNumbers - map to insert row numbers for each help field into - * @return the index of the next row to use - * @throws WriteException if any of the writes to workSheet failed - */ - private int outputHelp(WritableSheet workSheet, Table table, final int startRow, final Map helpTextRowNumbers) throws WriteException { - int currentRow = startRow; - - // Title for the descriptions - Label dataLabel = new Label(0, currentRow, "Column Descriptions"); - dataLabel.setCellFormat(getBoldFormat()); - workSheet.addCell(dataLabel); - currentRow++; - - int currentColumn = 0; - - for (Column column : table.columns()) { - if (!column.getName().equals("id") && !column.getName().equals("version")) { - // Field name to go with the description - Label fieldName = new Label(0, currentRow, spreadsheetifyName(column.getName())); - fieldName.setCellFormat(getBoldFormat()); - workSheet.addCell(fieldName); - - // The type/width - String typeString = column.getType() + "(" + column.getWidth() + (column.getScale() == 0 ? "" : "," + column.getScale()) + ")"; - Label fieldType = new Label(1, currentRow, typeString); - fieldType.setCellFormat(getStandardFormat()); - workSheet.addCell(fieldType); - - // The default - String defaultValue = additionalSchemaData.columnDefaultValue(table, column.getName()); - Label fieldDefault = new Label(2, currentRow, defaultValue); - fieldDefault.setCellFormat(getStandardFormat()); - workSheet.addCell(fieldDefault); - - // The field documentation - workSheet.mergeCells(3, currentRow, 12, currentRow); - String documentation = additionalSchemaData.columnDocumentation(table, column.getName()); - Label documentationLabel = new Label(3, currentRow, documentation); - WritableCellFormat format = new WritableCellFormat(getStandardFormat()); - format.setWrap(true); - format.setVerticalAlignment(VerticalAlignment.TOP); - documentationLabel.setCellFormat(format); - workSheet.addCell(documentationLabel); - - //If we've exceed the maximum number of columns - then output truncated warnings - if(currentColumn >= MAX_EXCEL_COLUMNS) { - Label truncatedWarning = new Label(13, currentRow, "[TRUNCATED]"); - truncatedWarning.setCellFormat(getBoldFormat()); - workSheet.addCell(truncatedWarning); - } - - // We are aiming for 150px. 1px is 15 Excel "Units" - workSheet.setRowView(currentRow, 150 * 15); - - // Remember at what row we created the help text for this column - helpTextRowNumbers.put(column.getName(), currentRow); - - currentRow++; - currentColumn++; - } - - } - - // Group all the help rows together - workSheet.setRowGroup(startRow + 1, currentRow - 1, true); - - // Some extra blank space for neatness - currentRow++; - - return currentRow; - } - - - /** - * Outputs the data headings row. - * - * @param workSheet to add the row to - * @param table to fetch metadata from - * @param startRow to add the headings at - * @param helpTextRowNumbers - the map of column names to row index for each - * bit of help text - * @throws WriteException if any of the writes to workSheet failed - * @return the row to carry on inserting at - */ - private int outputDataHeadings(WritableSheet workSheet, Table table, final int startRow, final Map helpTextRowNumbers) throws WriteException { - int currentRow = startRow; - - int columnNumber = 0; - final WritableCellFormat columnHeadingFormat = getBoldFormat(); - - columnHeadingFormat.setBackground(Colour.VERY_LIGHT_YELLOW); - WritableFont font = new WritableFont(WritableFont.ARIAL, 8, WritableFont.BOLD); - font.setColour(Colour.BLUE); - font.setUnderlineStyle(UnderlineStyle.SINGLE); - columnHeadingFormat.setFont(font); - - for (Column column : table.columns()) { - - if(columnNumber < MAX_EXCEL_COLUMNS && !column.getName().equals("id") && !column.getName().equals("version")) { - // Data heading is a link back to the help text - WritableHyperlink linkToHelp = new WritableHyperlink( - columnNumber, currentRow, - spreadsheetifyName(column.getName()), - workSheet, 0, helpTextRowNumbers.get(column.getName())); - workSheet.addHyperlink(linkToHelp); - WritableCell label = workSheet.getWritableCell(columnNumber, currentRow); - label.setCellFormat(columnHeadingFormat); - - // Update the help text such that it is a link to the heading - Cell helpCell = workSheet.getCell(0, helpTextRowNumbers.get(column.getName())); - WritableHyperlink linkFromHelp = new WritableHyperlink( - 0, helpTextRowNumbers.get(column.getName()), - helpCell.getContents(), - workSheet, columnNumber, currentRow); - workSheet.addHyperlink(linkFromHelp); - - columnNumber++; - } - } - - currentRow++; - - return currentRow; - } - - - /** - * @param row to add the record at - * @param worksheet to add the record to - * @param table that the record comes from - * @param record Record to serialise. This method is part of the old Cryo API. - */ - private void record(final int row, final WritableSheet worksheet, final Table table, Record record) { - int columnNumber = 0; - WritableFont standardFont = new WritableFont(WritableFont.ARIAL, 8); - WritableCellFormat standardFormat = new WritableCellFormat(standardFont); - - WritableCellFormat exampleFormat = new WritableCellFormat(standardFont); - try { - exampleFormat.setBackground(Colour.ICE_BLUE); - } catch (WriteException e) { - throw new RuntimeException("Failed to set example background colour", e); - } - - for (Column column : table.columns()) { - if(columnNumber < MAX_EXCEL_COLUMNS && !column.getName().equals("id") && !column.getName().equals("version")) { - createCell(worksheet, column, columnNumber, row, record, standardFormat); - columnNumber++; - } - } - } - - /** - * Creates the cell at the given position. - * - * @param currentWorkSheet to add the cell to - * @param column The meta data for the column in the source table - * @param columnNumber The column number to insert at (0 based) - * @param rowIndex The row number to insert at (0 based) - * @param record The source record - * @param format The format to apply to the cell - */ - private void createCell(final WritableSheet currentWorkSheet, Column column, int columnNumber, int rowIndex, Record record, WritableCellFormat format) { - WritableCell writableCell; - - switch (column.getType()) { - case STRING: - writableCell = new Label(columnNumber, rowIndex, record.getString(column.getName())); - break; - - case DECIMAL: - BigDecimal decimalValue = record.getBigDecimal(column.getName()); - try { - writableCell = decimalValue == null ? createBlankWriteableCell(columnNumber, rowIndex) : new jxl.write.Number(columnNumber, rowIndex, decimalValue.doubleValue()); - } catch (Exception e) { - throw new UnsupportedOperationException("Cannot generate Excel cell (parseDouble) for data [" + decimalValue + "]" + unsupportedOperationExceptionMessageSuffix(column, currentWorkSheet), e); - } - break; - - case BIG_INTEGER: - case INTEGER: - Long longValue = record.getLong(column.getName()); - try { - writableCell = longValue == null ? createBlankWriteableCell(columnNumber, rowIndex) : new jxl.write.Number(columnNumber, rowIndex, longValue); - } catch (Exception e) { - throw new UnsupportedOperationException("Cannot generate Excel cell (parseInt) for data [" + longValue + "]" + unsupportedOperationExceptionMessageSuffix(column, currentWorkSheet), e); - } - break; - - case CLOB: - try { - String stringValue = record.getString(column.getName()); - writableCell = stringValue == null ? createBlankWriteableCell(columnNumber, rowIndex) : new Label(columnNumber, rowIndex, StringUtils.substring(stringValue, 0, MAX_CELL_CHARACTERS)); - } catch (Exception e) { - throw new UnsupportedOperationException("Cannot generate Excel cell for CLOB data" + unsupportedOperationExceptionMessageSuffix(column, currentWorkSheet), e); - } - break; - - default: - throw new UnsupportedOperationException("Cannot output data type [" + column.getType() + "] to a spreadsheet"); - } - writableCell.setCellFormat(format); - - try { - currentWorkSheet.addCell(writableCell); - } catch (Exception e) { - throw new RuntimeException("Error writing value to spreadsheet", e); - } - } - - - /** - * Indicates if the table has a column with a column type which we can't - * output to a spreadsheet. - * - * @param table The table metadata. - * @return - */ - boolean tableHasUnsupportedColumns(Table table) { - return Iterables.any(table.columns(), new Predicate() { - @Override - public boolean apply(Column column) { - return !supportedDataTypes.contains(column.getType()); - } - }); - } - - - /** - * Thrown if the excel representation of the table has more than {@link TableOutputter#MAX_EXCEL_ROWS} - * - * @author Copyright (c) Alfa Financial Software 2017 - */ - private class RowLimitExceededException extends RuntimeException { - - public RowLimitExceededException(String message) { - super(message); - } - } - - - /** - * Creates a blank {@link WritableCell} for a given column number and row index. - * - * @param columnNumber the column number - * @param rowIndex the row index - * @return a blank {@link WritableCell} - */ - private WritableCell createBlankWriteableCell(int columnNumber, int rowIndex) { - return new jxl.write.Blank(columnNumber, rowIndex); - } - - - /** - * Creates an {@link UnsupportedOperationException} message suffix for a given - * {@link Column} and {@link WritableSheet}. - * - * @param column the {@link Column} - * @param writableSheet the {@link WritableSheet} - * @return the {@link UnsupportedOperationException} message suffix - */ - private String unsupportedOperationExceptionMessageSuffix(Column column, WritableSheet writableSheet) { - return " in column [" + column.getName() + "] of table [" + writableSheet.getName() + "]"; - } -} +/* Copyright 2017 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.excel; + +import static org.alfasoftware.morf.metadata.DataType.BIG_INTEGER; +import static org.alfasoftware.morf.metadata.DataType.CLOB; +import static org.alfasoftware.morf.metadata.DataType.DECIMAL; +import static org.alfasoftware.morf.metadata.DataType.INTEGER; +import static org.alfasoftware.morf.metadata.DataType.STRING; + +import java.math.BigDecimal; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.alfasoftware.morf.dataset.Record; +import org.alfasoftware.morf.metadata.Column; +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.metadata.Table; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.poi.common.usermodel.HyperlinkType; +import org.apache.poi.hssf.util.HSSFColor.HSSFColorPredefined; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.Hyperlink; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.WorkbookUtil; + +import com.google.common.base.Predicate; +import com.google.common.collect.Iterables; +import com.google.common.collect.Sets; + +/** + * Outputs tables to an excel spreadsheet. + */ +class TableOutputter { + + private static final Log log = LogFactory.getLog(TableOutputter.class); + private static final int NUMBER_OF_ROWS_IN_TITLE = 2; + private static final int MAX_EXCEL_ROWS = 65536; + private static final int MAX_EXCEL_COLUMNS = 256; + private static final int MAX_CELL_CHARACTERS = 32767; + private static final Set SUPPORTED_DATA_TYPES = Sets.immutableEnumSet(STRING, DECIMAL, BIG_INTEGER, INTEGER, CLOB); + + private final AdditionalSchemaData additionalSchemaData; + + public TableOutputter(AdditionalSchemaData additionalSchemaData) { + this.additionalSchemaData = additionalSchemaData; + } + + public void table(int maxSampleRows, final Workbook workbook, final Table table, final Iterable records) { + final Sheet worksheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(spreadsheetifyName(table.getName()))); + + boolean columnsTruncated = table.columns().size() > MAX_EXCEL_COLUMNS; + if (columnsTruncated) { + log.warn("Output for table '" + table.getName() + "' exceeds the maximum number of columns (" + MAX_EXCEL_COLUMNS + ") in an Excel worksheet. It will be truncated."); + } + + boolean rowsTruncated = false; + try { + int currentRow = NUMBER_OF_ROWS_IN_TITLE + 1; + try { + final Map helpTextRowNumbers = new HashMap<>(); + currentRow = outputHelp(worksheet, workbook, table, currentRow, helpTextRowNumbers); + writeValue(worksheet, currentRow++, 0, "Example Data", getBoldFormat(workbook)); + currentRow = outputDataHeadings(worksheet, workbook, table, currentRow, helpTextRowNumbers); + currentRow = outputExampleData(maxSampleRows, worksheet, workbook, table, currentRow, records); + writeValue(worksheet, currentRow++, 0, "Parameters to Set Up", getBoldFormat(workbook)); + currentRow = outputDataHeadings(worksheet, workbook, table, currentRow, helpTextRowNumbers); + outputExampleData(null, worksheet, workbook, table, currentRow, records); + } catch (RowLimitExceededException e) { + log.warn(e.getMessage()); + rowsTruncated = true; + } + } catch (Exception e) { + throw new RuntimeException("Error outputting table '" + table.getName() + "'", e); + } + + if (columnsTruncated || rowsTruncated) { + StringBuilder suffix = new StringBuilder(" ["); + if (columnsTruncated) { + suffix.append("COLUMNS"); + } + if (columnsTruncated && rowsTruncated) { + suffix.append(" & "); + } + if (rowsTruncated) { + suffix.append("ROWS"); + } + suffix.append(" TRUNCATED]"); + createTitle(worksheet, workbook, worksheet.getSheetName() + suffix, table.getName()); + } else { + createTitle(worksheet, workbook, worksheet.getSheetName(), table.getName()); + } + } + + private int outputHelp(Sheet worksheet, Workbook workbook, Table table, int startRow, Map helpTextRowNumbers) { + int currentRow = startRow; + int columnIndex = 0; + for (Column column : table.columns()) { + if (columnIndex >= MAX_EXCEL_COLUMNS) { + writeValue(worksheet, currentRow, columnIndex, "[TRUNCATED]", getStandardFormat(workbook)); + break; + } + helpTextRowNumbers.put(column.getName(), currentRow); + writeValue(worksheet, currentRow, 0, column.getName(), getBoldFormat(workbook)); + writeValue(worksheet, currentRow, 1, additionalSchemaData.columnDocumentation(table, column.getName()), getStandardFormat(workbook)); + writeValue(worksheet, currentRow, 2, additionalSchemaData.columnDefaultValue(table, column.getName()), getStandardFormat(workbook)); + currentRow++; + columnIndex++; + } + return currentRow + 1; + } + + private int outputDataHeadings(Sheet worksheet, Workbook workbook, Table table, int rowIndex, Map helpTextRowNumbers) { + int columnIndex = 0; + for (Column column : table.columns()) { + if (columnIndex >= MAX_EXCEL_COLUMNS) { + break; + } + Cell cell = writeValue(worksheet, rowIndex, columnIndex, spreadsheetifyName(column.getName()), getBoldHeadingFormat(workbook)); + Integer helpRow = helpTextRowNumbers.get(column.getName()); + if (helpRow != null) { + Hyperlink hyperlink = workbook.getCreationHelper().createHyperlink(HyperlinkType.DOCUMENT); + hyperlink.setAddress("'" + worksheet.getSheetName() + "'!A" + (helpRow + 1)); + cell.setHyperlink(hyperlink); + } + columnIndex++; + } + return rowIndex + 1; + } + + private int outputExampleData(Integer numberOfExamples, Sheet worksheet, Workbook workbook, Table table, int startRow, + Iterable records) { + int currentRow = startRow; + int written = 0; + for (Record record : records) { + if (numberOfExamples != null && written >= numberOfExamples) { + break; + } + if (currentRow >= MAX_EXCEL_ROWS - 1) { + throw new RowLimitExceededException("Output for table '" + table.getName() + "' exceeds the maximum number of rows (" + MAX_EXCEL_ROWS + ") in an Excel worksheet. It will be truncated."); + } + int columnIndex = 0; + for (Column column : table.columns()) { + if (columnIndex >= MAX_EXCEL_COLUMNS) { + writeValue(worksheet, currentRow, columnIndex, "[TRUNCATED]", getStandardFormat(workbook)); + break; + } + writeColumnValue(worksheet, workbook, currentRow, columnIndex, column, record); + columnIndex++; + } + currentRow++; + written++; + } + return currentRow + 1; + } + + private void writeColumnValue(Sheet worksheet, Workbook workbook, int rowIndex, int columnIndex, Column column, Record record) { + try { + switch (column.getType()) { + case STRING: + case CLOB: + String text = record.getString(column.getName()); + if (text == null) { + text = ""; + } + if (text.length() > MAX_CELL_CHARACTERS) { + text = text.substring(0, MAX_CELL_CHARACTERS); + } + writeValue(worksheet, rowIndex, columnIndex, text, getStandardFormat(workbook)); + break; + case DECIMAL: + BigDecimal decimal = record.getBigDecimal(column.getName()); + writeValue(worksheet, rowIndex, columnIndex, decimal == null ? "" : decimal.toString(), getStandardFormat(workbook)); + break; + case BIG_INTEGER: + case INTEGER: + Long longValue = record.getLong(column.getName()); + writeValue(worksheet, rowIndex, columnIndex, longValue == null ? "0" : longValue.toString(), getStandardFormat(workbook)); + break; + default: + throw new UnsupportedOperationException("Unsupported data type " + column.getType() + unsupportedOperationExceptionMessageSuffix(column, worksheet)); + } + } catch (RuntimeException e) { + String message = column.getType() == CLOB + ? "Cannot generate Excel cell for CLOB data" + unsupportedOperationExceptionMessageSuffix(column, worksheet) + : "Cannot generate Excel cell for data" + unsupportedOperationExceptionMessageSuffix(column, worksheet); + throw new UnsupportedOperationException(message, e); + } + } + + private Cell writeValue(Sheet sheet, int rowIndex, int columnIndex, String value, CellStyle style) { + Row row = getOrCreateRow(sheet, rowIndex); + Cell cell = row.createCell(columnIndex); + cell.setCellValue(value); + cell.setCellStyle(style); + return cell; + } + + private void createTitle(Sheet sheet, Workbook workbook, String title, String fileName) { + Cell titleCell = writeValue(sheet, 0, 0, title, titleStyle(workbook)); + titleCell.setCellStyle(titleStyle(workbook)); + + Cell fileNameCell = writeValue(sheet, 1, 1, fileName, hiddenFileNameStyle(workbook)); + fileNameCell.setCellStyle(hiddenFileNameStyle(workbook)); + + Cell copyrightCell = writeValue(sheet, 0, 12, + "Copyright " + new SimpleDateFormat("yyyy").format(new Date()) + " Alfa Financial Software Ltd.", + copyrightStyle(workbook)); + copyrightCell.setCellStyle(copyrightStyle(workbook)); + } + + private CellStyle titleStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setBold(true); + font.setFontHeightInPoints((short) 16); + style.setFont(font); + return style; + } + + private CellStyle hiddenFileNameStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setColor(HSSFColorPredefined.WHITE.getIndex()); + style.setFont(font); + return style; + } + + private CellStyle copyrightStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setAlignment(HorizontalAlignment.RIGHT); + return style; + } + + private String spreadsheetifyName(String name) { + return StringUtils.capitalize(name).replaceAll("([A-Z][a-z])", " $1").trim(); + } + + private CellStyle getStandardFormat(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setVerticalAlignment(VerticalAlignment.TOP); + Font font = workbook.createFont(); + font.setFontHeightInPoints((short) 8); + style.setFont(font); + return style; + } + + private CellStyle getBoldFormat(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setVerticalAlignment(VerticalAlignment.TOP); + Font font = workbook.createFont(); + font.setBold(true); + font.setFontHeightInPoints((short) 8); + style.setFont(font); + return style; + } + + private CellStyle getBoldHeadingFormat(Workbook workbook) { + CellStyle style = getBoldFormat(workbook); + style.setBorderBottom(BorderStyle.MEDIUM); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setFillForegroundColor(HSSFColorPredefined.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + return style; + } + + boolean tableHasUnsupportedColumns(Table table) { + return Iterables.any(table.columns(), new Predicate() { + @Override + public boolean apply(Column column) { + return !SUPPORTED_DATA_TYPES.contains(column.getType()); + } + }); + } + + private Row getOrCreateRow(Sheet sheet, int rowIndex) { + Row row = sheet.getRow(rowIndex); + return row == null ? sheet.createRow(rowIndex) : row; + } + + private String unsupportedOperationExceptionMessageSuffix(Column column, Sheet worksheet) { + return " in column [" + column.getName() + "] of table [" + worksheet.getSheetName() + "]"; + } + + private static class RowLimitExceededException extends RuntimeException { + RowLimitExceededException(String message) { + super(message); + } + } +} diff --git a/morf-excel/src/main/resources/org/alfasoftware/morf/excel/package.html b/morf-excel/src/main/resources/org/alfasoftware/morf/excel/package.html old mode 100755 new mode 100644 diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java old mode 100755 new mode 100644 index 7db52ec74..d2285b858 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java @@ -18,8 +18,8 @@ import static org.alfasoftware.morf.metadata.SchemaUtils.table; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; -import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -31,58 +31,35 @@ import org.alfasoftware.morf.dataset.Record; import org.alfasoftware.morf.metadata.Table; +import org.apache.poi.ss.usermodel.Workbook; import org.junit.Test; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import jxl.write.WritableWorkbook; - - - -/** - * Ensure that {@link SpreadsheetDataSetConsumer} works correctly. Particularly: - * - *
    - *
  • configuration must be picked up correctly
  • - *
- * - * @author Copyright (c) Alfa Financial Software 2010 - */ public class TestSpreadsheetDataSetConsumer { private static final ImmutableList NO_RECORDS = ImmutableList.of(); - - /** - * Ensure that a table that is not configured is not output. - */ @Test public void testIgnoreTable() { final MockTableOutputter outputter = new MockTableOutputter(); - - final SpreadsheetDataSetConsumer consumer = - new SpreadsheetDataSetConsumer( + final SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( mock(OutputStream.class), Optional.>of(ImmutableMap.of("COMPANY", 5)), - outputter - ); + outputter); consumer.table(table("NotCompany"), NO_RECORDS); - assertNull("Table not passed through", outputter.tableReceived); } - @Test public void testUnsupportedColumns() { TableOutputter outputter = mock(TableOutputter.class); - final SpreadsheetDataSetConsumer consumer = - new SpreadsheetDataSetConsumer( - mock(OutputStream.class), - Optional.>empty(), - outputter - ); + final SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( + mock(OutputStream.class), + Optional.>empty(), + outputter); Table one = table("one"); Table two = table("two"); @@ -92,59 +69,36 @@ public void testUnsupportedColumns() { consumer.table(one, NO_RECORDS); consumer.table(two, NO_RECORDS); - verify(outputter).table(nullable(Integer.class), nullable(WritableWorkbook.class), eq(two), eq(NO_RECORDS)); - verify(outputter, times(0)).table(nullable(Integer.class), nullable(WritableWorkbook.class), eq(one), eq(NO_RECORDS)); + verify(outputter).table(nullable(Integer.class), nullable(Workbook.class), eq(two), eq(NO_RECORDS)); + verify(outputter, times(0)).table(nullable(Integer.class), nullable(Workbook.class), eq(one), eq(NO_RECORDS)); } + @Test + public void testIncludeTableWithSpecificRowCount() { + final MockTableOutputter outputter = new MockTableOutputter(); + SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( + mock(OutputStream.class), + Optional.>of(ImmutableMap.of("COMPANY", 5)), + outputter); + + consumer.table(table("Company"), NO_RECORDS); - /** - * Mock for {@link TableOutputter} that tracks what table should have been - * output and with how many rows. - * - * @author Copyright (c) Alfa Financial Software 2010 - */ - private static class MockTableOutputter extends TableOutputter { + assertEquals("Table passed through for output", "Company", outputter.tableReceived); + assertEquals("Number of rows desired", Integer.valueOf(5), outputter.rowCountReceived); + } - /** - * The name of the table passed in to {@link #table(TableEntry, WritableWorkbook, Table, Iterable)}. - */ + private static class MockTableOutputter extends TableOutputter { private String tableReceived; - - /** - * The number of rows that were requested in the output. - */ private Number rowCountReceived; - public MockTableOutputter() { + MockTableOutputter() { super(new DefaultAdditionalSchemaDataImpl()); } - /** - * @see org.alfasoftware.morf.excel.TableOutputter#table(com.chpconsulting.cryo.excel.TableConfigurationProvider.TableEntry, jxl.write.WritableWorkbook, org.alfasoftware.morf.metadata.Table, java.lang.Iterable) - */ @Override - public void table(int maxRows, WritableWorkbook workbook, Table table, Iterable records) { + public void table(int maxRows, Workbook workbook, Table table, Iterable records) { tableReceived = table.getName(); rowCountReceived = maxRows; } } - - - /** - * Ensure that a table with a specific row count is included in the set of - * tables that are output. - */ - @Test - public void testIncludeTableWithSpecificRowCount() { - final MockTableOutputter outputter = new MockTableOutputter(); - SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( - mock(OutputStream.class), - Optional.>of(ImmutableMap.of("COMPANY", 5)), - outputter - ); - consumer.table(table("Company"), NO_RECORDS); - - assertEquals("Table passed through for output", "Company", outputter.tableReceived); - assertEquals("Number of rows desired", Integer.valueOf(5), outputter.rowCountReceived); - } } diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java old mode 100755 new mode 100644 diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java index 34759b858..44e0226c8 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java @@ -1,13 +1,10 @@ package org.alfasoftware.morf.excel; import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.math.BigDecimal; @@ -18,33 +15,23 @@ import org.alfasoftware.morf.metadata.Column; import org.alfasoftware.morf.metadata.DataType; import org.alfasoftware.morf.metadata.Table; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; import org.junit.Before; import org.junit.Test; -import org.mockito.ArgumentCaptor; import com.google.common.collect.ImmutableList; -import jxl.Cell; -import jxl.write.WritableCell; -import jxl.write.WritableSheet; -import jxl.write.WritableWorkbook; -import jxl.write.WriteException; -/** - * Ensure that {@link TableOutputter} works correctly. - * - * @author Copyright (c) Alfa Financial Software 2023 - */ public class TestTableOutputter { private Table table; private TableOutputter tableOutputter; - private WritableSheet writableSheet; - private WritableWorkbook writableWorkbook; + private Workbook workbook; + private Sheet sheet; + private final DataFormatter formatter = new DataFormatter(); - - /** - * Set up the test. - */ @Before public void setUp() { table = mock(Table.class); @@ -54,247 +41,146 @@ public void setUp() { column("Col2", DataType.DECIMAL), column("Col3", DataType.BIG_INTEGER), column("Col4", DataType.INTEGER), - column("Col5", DataType.CLOB) - )); - - writableWorkbook = mock(WritableWorkbook.class); - writableSheet = mock(WritableSheet.class); - when(writableSheet.getName()).thenReturn("Sheet1"); - when(writableSheet.getWritableCell(anyInt(), anyInt())).thenReturn(mock(WritableCell.class)); - when(writableSheet.getCell(anyInt(), anyInt())).thenReturn(mock(Cell.class)); - when(writableWorkbook.createSheet(any(String.class), any(Integer.class))).thenReturn(writableSheet); + column("Col5", DataType.CLOB))); + workbook = new HSSFWorkbook(); AdditionalSchemaData additionalSchemaData = mock(AdditionalSchemaData.class); + when(additionalSchemaData.columnDocumentation(table, "Col1")).thenReturn(""); + when(additionalSchemaData.columnDocumentation(table, "Col2")).thenReturn(""); + when(additionalSchemaData.columnDocumentation(table, "Col3")).thenReturn(""); + when(additionalSchemaData.columnDocumentation(table, "Col4")).thenReturn(""); + when(additionalSchemaData.columnDocumentation(table, "Col5")).thenReturn(""); + when(additionalSchemaData.columnDefaultValue(table, "Col1")).thenReturn(""); + when(additionalSchemaData.columnDefaultValue(table, "Col2")).thenReturn(""); + when(additionalSchemaData.columnDefaultValue(table, "Col3")).thenReturn(""); + when(additionalSchemaData.columnDefaultValue(table, "Col4")).thenReturn(""); + when(additionalSchemaData.columnDefaultValue(table, "Col5")).thenReturn(""); tableOutputter = new TableOutputter(additionalSchemaData); } - - /** - * Tests that a table with fields containing different populated data types is written correctly to the worksheet. - * - * @throws {@link WriteException} if an error occurs - */ @Test - public void testTableOutputWithFieldsPopulated() throws WriteException { - // Given + public void testTableOutputWithFieldsPopulated() { Record record1 = mock(Record.class); when(record1.getString("Col1")).thenReturn("STRING VALUE"); when(record1.getBigDecimal("Col2")).thenReturn(BigDecimal.valueOf(12.34)); when(record1.getLong("Col3")).thenReturn(22L); when(record1.getLong("Col4")).thenReturn(33L); when(record1.getString("Col5")).thenReturn("CLOB VALUE"); - ImmutableList recordList = ImmutableList.of(record1); - // When - tableOutputter.table(1, writableWorkbook, table, recordList); + tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); + sheet = workbook.getSheetAt(0); - ArgumentCaptor writableCellCaptor = ArgumentCaptor.forClass(WritableCell.class); - verify(writableSheet, times(36)).addCell(writableCellCaptor.capture()); - List capturedWritableCellList = writableCellCaptor.getAllValues(); - // Example Data - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 0, "STRING VALUE")); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 1, BigDecimal.valueOf(12.34).toString())); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 2, Long.valueOf(22).toString())); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 3, Long.valueOf(33).toString())); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 4, "CLOB VALUE")); + assertEquals("STRING VALUE", value(12, 0)); + assertEquals("12.34", value(12, 1)); + assertEquals("22", value(12, 2)); + assertEquals("33", value(12, 3)); + assertEquals("CLOB VALUE", value(12, 4)); - // Parameters to Set Up - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,16, 0, "STRING VALUE")); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,16, 1, BigDecimal.valueOf(12.34).toString())); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,16, 2, Long.valueOf(22).toString())); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,16, 3, Long.valueOf(33).toString())); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,16, 4, "CLOB VALUE")); + assertEquals("STRING VALUE", value(16, 0)); + assertEquals("12.34", value(16, 1)); + assertEquals("22", value(16, 2)); + assertEquals("33", value(16, 3)); + assertEquals("CLOB VALUE", value(16, 4)); } - - /** - * Tests that a table with fields containing different unpopulated data types is written correctly to the worksheet. - * - * @throws {@link WriteException} if an error occurs - */ @Test - public void testTableOutputWithFieldsUnpopulated() throws WriteException { - // Given + public void testTableOutputWithFieldsUnpopulated() { Record record1 = mock(Record.class); - ImmutableList recordList = ImmutableList.of(record1); - // When - tableOutputter.table(1, writableWorkbook, table, recordList); + tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); + sheet = workbook.getSheetAt(0); - ArgumentCaptor writableCellCaptor = ArgumentCaptor.forClass(WritableCell.class); - verify(writableSheet, times(36)).addCell(writableCellCaptor.capture()); - List capturedWritableCellList = writableCellCaptor.getAllValues(); - // Example Data - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 0, "")); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 1, "")); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 2, "0")); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 3, "0")); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 4, "")); + assertEquals("", value(12, 0)); + assertEquals("", value(12, 1)); + assertEquals("0", value(12, 2)); + assertEquals("0", value(12, 3)); + assertEquals("", value(12, 4)); - // Parameters to Set Up - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,16, 0, "")); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,16, 1, "")); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,16, 2, "0")); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,16, 3, "0")); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,16, 4, "")); + assertEquals("", value(16, 0)); + assertEquals("", value(16, 1)); + assertEquals("0", value(16, 2)); + assertEquals("0", value(16, 3)); + assertEquals("", value(16, 4)); } - - /** - * Tests that columns are truncated correctly. - * - * @throws {@link WriteException} if an error occurs - */ @Test - public void testColumnTruncation() throws WriteException { - // Given + public void testColumnTruncation() { Record record1 = mock(Record.class); List columnList = new ArrayList<>(); - for (int c = 1; c <= 257; c++) { columnList.add(column("Col" + c, DataType.STRING)); - when(record1.getString("Col") + c).thenReturn("Value" + c); + when(record1.getString("Col" + c)).thenReturn("Value" + c); } - when(table.columns()).thenReturn(columnList); - ImmutableList recordList = ImmutableList.of(record1); - // When - tableOutputter.table(1, writableWorkbook, table, recordList); + tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); + sheet = workbook.getSheetAt(0); - // Then - ArgumentCaptor writableCellCaptor = ArgumentCaptor.forClass(WritableCell.class); - verify(writableSheet, times(1547)).addCell(writableCellCaptor.capture()); - List capturedWritableCellList = writableCellCaptor.getAllValues(); - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,260, 13, "[TRUNCATED]")); + assertEquals("[TRUNCATED]", value(12, 256)); } - - - /** - * Tests that rows are truncated correctly. - * - * @throws {@link WriteException} if an error occurs - */ @Test - public void testRowTruncation() throws WriteException { - // Given - Record record1 = mock(Record.class); + public void testRowTruncation() { when(table.columns()).thenReturn(ImmutableList.of(column("Col1", DataType.STRING))); List recordList = new ArrayList<>(); - for (int r = 1; r < 65537; r++) { recordList.add(mock(Record.class)); } - // When - tableOutputter.table(1, writableWorkbook, table, recordList); - - // Then - ArgumentCaptor writableCellCaptor = ArgumentCaptor.forClass(WritableCell.class); - verify(writableSheet, times(65535)).addCell(writableCellCaptor.capture()); - List capturedWritableCellList = writableCellCaptor.getAllValues(); + tableOutputter.table(1, workbook, table, recordList); + sheet = workbook.getSheetAt(0); - for (int i = 0; i < capturedWritableCellList.size(); i++) { - if (capturedWritableCellList.get(i).getContents().contains("TRUNC")) { - System.out.println("x"); - } - } - - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,0, 0, "Sheet1 [ROWS TRUNCATED]")); + assertTrue(value(0, 0).contains("ROWS TRUNCATED")); } - - /** - * Tests that a clob value that exceeds the max length supported by Excel is truncated before being written to the worksheet. - * - * @throws {@link WriteException} if an error occurs - */ @Test - public void testClobTruncation() throws WriteException { - // Given - when(table.columns()).thenReturn(ImmutableList.of( - column("Col1", DataType.CLOB) - )); - + public void testClobTruncation() { + when(table.columns()).thenReturn(ImmutableList.of(column("Col1", DataType.CLOB))); Record record1 = mock(Record.class); - - String clobValue = ""; - + StringBuilder clobValue = new StringBuilder(); for (int c = 0; c < 35000; c++) { - clobValue += "X"; + clobValue.append('X'); } + when(record1.getString("Col1")).thenReturn(clobValue.toString()); - when(record1.getString("Col1")).thenReturn(clobValue); - ImmutableList recordList = ImmutableList.of(record1); - int expectedMaxClobLength = 32767; - - // When - tableOutputter.table(1, writableWorkbook, table, recordList); - - ArgumentCaptor writableCellCaptor = ArgumentCaptor.forClass(WritableCell.class); - verify(writableSheet, times(12)).addCell(writableCellCaptor.capture()); - List capturedWritableCellList = writableCellCaptor.getAllValues(); - // Example Data - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 0, clobValue.substring(0, expectedMaxClobLength))); + tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); + sheet = workbook.getSheetAt(0); - // Parameters to Set Up - assertTrue(isCapturedWritableCellListContains(capturedWritableCellList,12, 0, clobValue.substring(0, expectedMaxClobLength))); + assertEquals(32767, value(12, 0).length()); + assertEquals(32767, value(16, 0).length()); } - - /** - * Tests that a table with fields containing invalid data results in a {@link UnsupportedOperationException}. - */ @Test public void testUnsupportedOperationExceptionHandling() { - // Given Record record1 = mock(Record.class); - // DECIMAL when(table.columns()).thenReturn(ImmutableList.of(column("Col1", DataType.DECIMAL))); BigDecimal bigDecimal = mock(BigDecimal.class); when(record1.getBigDecimal("Col1")).thenReturn(bigDecimal); - when(bigDecimal.doubleValue()).thenThrow(new RuntimeException("BAD BIG DECIMAL")); + when(bigDecimal.toString()).thenThrow(new RuntimeException("BAD BIG DECIMAL")); try { - // When - tableOutputter.table(1, writableWorkbook, table, ImmutableList.of(record1)); + tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); fail("UnsupportedOperationException should be thrown"); } catch (Exception e) { - // Then - assertTrue(e.getCause().getMessage().startsWith("Cannot generate Excel cell (parseDouble) for data [Mock for BigDecimal")); + assertTrue(e.getCause().getMessage().startsWith("Cannot generate Excel cell for data")); } - // CLOB when(table.columns()).thenReturn(ImmutableList.of(column("Col1", DataType.CLOB))); when(record1.getString("Col1")).thenThrow(new RuntimeException("BAD CLOB")); try { - // When - tableOutputter.table(1, writableWorkbook, table, ImmutableList.of(record1)); + tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); fail("UnsupportedOperationException should be thrown"); } catch (Exception e) { - // Then assertTrue(e.getCause().getMessage().startsWith("Cannot generate Excel cell for CLOB data")); } } - - /** - * Tests whether a given {@link WritableCell} {@link List} contains an entry with a given row, column and value. - * - * @param capturedWritableCellList the {@link WritableCell} {@link List} - * @param row the row - * @param col the column - * @param value the value - * @return true if capturedWritableCellList contains the entry, otherwise false - */ - private boolean isCapturedWritableCellListContains(List capturedWritableCellList, int row, int col, String value) { - return capturedWritableCellList.stream() - .filter(writableCell -> writableCell.getRow() == row && - writableCell.getColumn() == col && - writableCell.getContents().equals(value)) - .count() == 1; + private String value(int rowIndex, int colIndex) { + if (sheet.getRow(rowIndex) == null || sheet.getRow(rowIndex).getCell(colIndex) == null) { + return ""; + } + return formatter.formatCellValue(sheet.getRow(rowIndex).getCell(colIndex)); } } diff --git a/morf-excel/src/test/resources/commons-logging.properties b/morf-excel/src/test/resources/commons-logging.properties deleted file mode 100644 index 4e88c3c10..000000000 --- a/morf-excel/src/test/resources/commons-logging.properties +++ /dev/null @@ -1,3 +0,0 @@ -# This is here to override other files that might get picked up on the classpath from third party JARs. -org.apache.commons.logging.LogFactory=org.apache.commons.logging.impl.LogFactoryImpl -org.apache.commons.logging.Log=org.apache.commons.logging.impl.Log4JLogger \ No newline at end of file diff --git a/morf-excel/src/test/resources/log4j.properties b/morf-excel/src/test/resources/log4j.properties deleted file mode 100644 index 29d0a92e9..000000000 --- a/morf-excel/src/test/resources/log4j.properties +++ /dev/null @@ -1,9 +0,0 @@ -# Logging configuration - -log4j.rootLogger=INFO, CONSOLE - -log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender -log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout -log4j.appender.CONSOLE.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n - -log4j.logger.org.alfasoftware.morf.diagnostics.DatabaseAndSystemTimeConsistencyChecker=DEBUG diff --git a/morf-excel/src/test/resources/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.xls b/morf-excel/src/test/resources/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.xls deleted file mode 100755 index 23bdf9854de782c5bdfc25ca98aea156f1949c0f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 240128 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 cbjw2.1 4.13.1 1.0.4 - 2.6.12 + 5.5.1 1.2.16 5.4.0 2.6.9 @@ -356,9 +356,43 @@ ${junitparams.version} - net.sourceforge.jexcelapi - jxl - ${jxl.version} + org.apache.poi + poi + ${apache-poi.version} + + + org.slf4j + jcl-over-slf4j + + + + + org.apache.poi + poi-ooxml + ${apache-poi.version} + + + org.apache.xmlgraphics + batik-all + + + de.rototor.pdfbox + graphics2d + + + org.bouncycastle + bcpkix-jdk15on + + + org.bouncycastle + bcprov-jdk15on + + + + + org.apache.poi + poi-ooxml-lite + ${apache-poi.version} log4j From 9c74713da0b30ff1247a6a48a1b908906c5e0f65 Mon Sep 17 00:00:00 2001 From: Jon Knight Date: Tue, 21 Apr 2026 11:17:03 +0100 Subject: [PATCH 2/8] SCA tidy up --- .../morf/excel/AdditionalSchemaData.java | 4 ++-- .../morf/excel/SpreadsheetDataSetConsumer.java | 2 +- .../morf/excel/SpreadsheetDataSetProducer.java | 2 +- .../alfasoftware/morf/excel/TableOutputter.java | 8 +------- .../morf/excel/TestSpreadsheetDataSetConsumer.java | 11 +++++------ .../morf/excel/TestSpreadsheetDataSetProducer.java | 14 ++++---------- .../morf/excel/TestTableOutputter.java | 6 +----- 7 files changed, 15 insertions(+), 32 deletions(-) diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java index a360264ad..eecef5296 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java @@ -31,7 +31,7 @@ public interface AdditionalSchemaData { * @param columnName The column name. * @return The column documentation. */ - public String columnDocumentation(Table table, String columnName); + String columnDocumentation(Table table, String columnName); /** * Fetches the default value for a column. @@ -40,5 +40,5 @@ public interface AdditionalSchemaData { * @param columnName The column name. * @return The column's default value. */ - public String columnDefaultValue(Table table, String columnName); + String columnDefaultValue(Table table, String columnName); } diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java index 7ac67003d..defd22b8f 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java @@ -57,7 +57,7 @@ public class SpreadsheetDataSetConsumer implements DataSetConsumer { private final TableOutputter tableOutputter; public SpreadsheetDataSetConsumer(OutputStream documentOutputStream) { - this(documentOutputStream, Optional.>empty()); + this(documentOutputStream, Optional.empty()); } public SpreadsheetDataSetConsumer(OutputStream documentOutputStream, Optional> rowsPerTable) { diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java index 4ae1e1ca0..e363d2240 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java @@ -202,7 +202,7 @@ private Record createRecord(final long id, final Map columnHead final Sheet sheet, final int rowIndex) { final int translationId; String translationValue = translationColumn == -1 ? "" : getCellContents(sheet, translationColumn, rowIndex); - if (translationColumn != -1 && translationValue.length() > 0) { + if (translationColumn != -1 && !translationValue.isEmpty()) { translationId = translations.size() + 1; translations.add(createTranslationRecord(translationId, translationValue)); } else { diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java index f42612eb6..492c9a847 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java @@ -50,7 +50,6 @@ import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.util.WorkbookUtil; -import com.google.common.base.Predicate; import com.google.common.collect.Iterables; import com.google.common.collect.Sets; @@ -291,12 +290,7 @@ private CellStyle getBoldHeadingFormat(Workbook workbook) { } boolean tableHasUnsupportedColumns(Table table) { - return Iterables.any(table.columns(), new Predicate() { - @Override - public boolean apply(Column column) { - return !SUPPORTED_DATA_TYPES.contains(column.getType()); - } - }); + return Iterables.any(table.columns(), column -> !SUPPORTED_DATA_TYPES.contains(column.getType())); } private Row getOrCreateRow(Sheet sheet, int rowIndex) { diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java index d2285b858..76003012f 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java @@ -26,7 +26,6 @@ import static org.mockito.Mockito.when; import java.io.OutputStream; -import java.util.Map; import java.util.Optional; import org.alfasoftware.morf.dataset.Record; @@ -39,14 +38,14 @@ public class TestSpreadsheetDataSetConsumer { - private static final ImmutableList NO_RECORDS = ImmutableList.of(); + private static final ImmutableList NO_RECORDS = ImmutableList.of(); @Test public void testIgnoreTable() { final MockTableOutputter outputter = new MockTableOutputter(); final SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( mock(OutputStream.class), - Optional.>of(ImmutableMap.of("COMPANY", 5)), + Optional.of(ImmutableMap.of("COMPANY", 5)), outputter); consumer.table(table("NotCompany"), NO_RECORDS); @@ -58,7 +57,7 @@ public void testUnsupportedColumns() { TableOutputter outputter = mock(TableOutputter.class); final SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( mock(OutputStream.class), - Optional.>empty(), + Optional.empty(), outputter); Table one = table("one"); @@ -78,13 +77,13 @@ public void testIncludeTableWithSpecificRowCount() { final MockTableOutputter outputter = new MockTableOutputter(); SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( mock(OutputStream.class), - Optional.>of(ImmutableMap.of("COMPANY", 5)), + Optional.of(ImmutableMap.of("COMPANY", 5)), outputter); consumer.table(table("Company"), NO_RECORDS); assertEquals("Table passed through for output", "Company", outputter.tableReceived); - assertEquals("Number of rows desired", Integer.valueOf(5), outputter.rowCountReceived); + assertEquals("Number of rows desired", 5, outputter.rowCountReceived); } private static class MockTableOutputter extends TableOutputter { diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java index fe2d6888e..f68f93268 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java @@ -21,7 +21,6 @@ import static org.junit.Assert.fail; import java.io.InputStream; -import java.net.URISyntaxException; import java.util.Collection; import java.util.List; @@ -39,11 +38,9 @@ public class TestSpreadsheetDataSetProducer { /** * Ensure that the schema can be retrieved from a set of excel files - * - * @throws URISyntaxException ... */ @Test - public void testGetSchema() throws URISyntaxException { + public void testGetSchema() { final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); // Check that the table names were picked up correctly @@ -57,10 +54,9 @@ public void testGetSchema() throws URISyntaxException { /** * Ensure that the rows can be retrieved from a set of excel files * - * @throws URISyntaxException ... */ @Test - public void testGetRecords() throws URISyntaxException { + public void testGetRecords() { final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); // Check that the table names were picked up correctly @@ -81,10 +77,9 @@ record = records.get(1); /** * Ensure that the generated schema behaves as expected * - * @throws URISyntaxException ... */ @Test - public void testGetSchemaMethods() throws URISyntaxException { + public void testGetSchemaMethods() { final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); try { // Getting the metadata of a table is an unsupported method of the @@ -110,7 +105,6 @@ public void testGetSchemaMethods() throws URISyntaxException { private SpreadsheetDataSetProducer produceTestSpreadsheet() { InputStream testExcel = getClass().getResourceAsStream("TestSpreadsheetDataSetProducer.xls"); - final SpreadsheetDataSetProducer producer = new SpreadsheetDataSetProducer(testExcel); - return producer; + return new SpreadsheetDataSetProducer(testExcel); } } diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java index 44e0226c8..f281bd39c 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java @@ -137,11 +137,7 @@ public void testRowTruncation() { public void testClobTruncation() { when(table.columns()).thenReturn(ImmutableList.of(column("Col1", DataType.CLOB))); Record record1 = mock(Record.class); - StringBuilder clobValue = new StringBuilder(); - for (int c = 0; c < 35000; c++) { - clobValue.append('X'); - } - when(record1.getString("Col1")).thenReturn(clobValue.toString()); + when(record1.getString("Col1")).thenReturn("X".repeat(35000)); tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); sheet = workbook.getSheetAt(0); From 1bfee531958f2840323de388240bcfcc879b4e9a Mon Sep 17 00:00:00 2001 From: Jon Knight Date: Tue, 21 Apr 2026 13:25:27 +0100 Subject: [PATCH 3/8] Fix unit tests --- .../morf/excel/AdditionalSchemaData.java | 4 +- .../excel/SpreadsheetDataSetConsumer.java | 2 +- .../excel/SpreadsheetDataSetProducer.java | 5 +- .../morf/excel/TableOutputter.java | 250 ++++++++++++------ .../excel/TestSpreadsheetDataSetConsumer.java | 11 +- .../excel/TestSpreadsheetDataSetProducer.java | 14 +- .../morf/excel/TestTableOutputter.java | 29 +- 7 files changed, 221 insertions(+), 94 deletions(-) diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java index eecef5296..a360264ad 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java @@ -31,7 +31,7 @@ public interface AdditionalSchemaData { * @param columnName The column name. * @return The column documentation. */ - String columnDocumentation(Table table, String columnName); + public String columnDocumentation(Table table, String columnName); /** * Fetches the default value for a column. @@ -40,5 +40,5 @@ public interface AdditionalSchemaData { * @param columnName The column name. * @return The column's default value. */ - String columnDefaultValue(Table table, String columnName); + public String columnDefaultValue(Table table, String columnName); } diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java index defd22b8f..7ac67003d 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java @@ -57,7 +57,7 @@ public class SpreadsheetDataSetConsumer implements DataSetConsumer { private final TableOutputter tableOutputter; public SpreadsheetDataSetConsumer(OutputStream documentOutputStream) { - this(documentOutputStream, Optional.empty()); + this(documentOutputStream, Optional.>empty()); } public SpreadsheetDataSetConsumer(OutputStream documentOutputStream, Optional> rowsPerTable) { diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java index e363d2240..76f395af6 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java @@ -75,6 +75,9 @@ private Record createTranslationRecord(final int id, final String translation) { } private void parseWorkbook(final InputStream inputStream) { + if (inputStream == null) { + throw new IllegalArgumentException("Spreadsheet input stream was null"); + } try (Workbook workbook = WorkbookFactory.create(inputStream)) { final Sheet sheet = workbook.getSheetAt(0); final int column = 1; @@ -202,7 +205,7 @@ private Record createRecord(final long id, final Map columnHead final Sheet sheet, final int rowIndex) { final int translationId; String translationValue = translationColumn == -1 ? "" : getCellContents(sheet, translationColumn, rowIndex); - if (translationColumn != -1 && !translationValue.isEmpty()) { + if (translationColumn != -1 && translationValue.length() > 0) { translationId = translations.size() + 1; translations.add(createTranslationRecord(translationId, translationValue)); } else { diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java index 492c9a847..f054bff84 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java @@ -25,6 +25,7 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; @@ -40,6 +41,7 @@ import org.apache.poi.ss.usermodel.BorderStyle; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.FillPatternType; import org.apache.poi.ss.usermodel.Font; import org.apache.poi.ss.usermodel.HorizontalAlignment; @@ -50,6 +52,7 @@ import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.util.WorkbookUtil; +import com.google.common.base.Predicate; import com.google.common.collect.Iterables; import com.google.common.collect.Sets; @@ -66,6 +69,7 @@ class TableOutputter { private static final Set SUPPORTED_DATA_TYPES = Sets.immutableEnumSet(STRING, DECIMAL, BIG_INTEGER, INTEGER, CLOB); private final AdditionalSchemaData additionalSchemaData; + private final Map styleCache = new IdentityHashMap<>(); public TableOutputter(AdditionalSchemaData additionalSchemaData) { this.additionalSchemaData = additionalSchemaData; @@ -119,18 +123,25 @@ public void table(int maxSampleRows, final Workbook workbook, final Table table, private int outputHelp(Sheet worksheet, Workbook workbook, Table table, int startRow, Map helpTextRowNumbers) { int currentRow = startRow; - int columnIndex = 0; + writeValue(worksheet, currentRow++, 0, "Column Descriptions", getBoldFormat(workbook)); + + int currentColumn = 0; for (Column column : table.columns()) { - if (columnIndex >= MAX_EXCEL_COLUMNS) { - writeValue(worksheet, currentRow, columnIndex, "[TRUNCATED]", getStandardFormat(workbook)); - break; + if ("id".equals(column.getName()) || "version".equals(column.getName())) { + continue; } - helpTextRowNumbers.put(column.getName(), currentRow); - writeValue(worksheet, currentRow, 0, column.getName(), getBoldFormat(workbook)); - writeValue(worksheet, currentRow, 1, additionalSchemaData.columnDocumentation(table, column.getName()), getStandardFormat(workbook)); + + writeValue(worksheet, currentRow, 0, spreadsheetifyName(column.getName()), getBoldFormat(workbook)); + String typeString = column.getType() + "(" + column.getWidth() + (column.getScale() == 0 ? "" : "," + column.getScale()) + ")"; + writeValue(worksheet, currentRow, 1, typeString, getStandardFormat(workbook)); writeValue(worksheet, currentRow, 2, additionalSchemaData.columnDefaultValue(table, column.getName()), getStandardFormat(workbook)); + writeValue(worksheet, currentRow, 3, additionalSchemaData.columnDocumentation(table, column.getName()), getWrappedFormat(workbook)); + if (currentColumn >= MAX_EXCEL_COLUMNS) { + writeValue(worksheet, currentRow, 13, "[TRUNCATED]", getBoldFormat(workbook)); + } + helpTextRowNumbers.put(column.getName(), currentRow); currentRow++; - columnIndex++; + currentColumn++; } return currentRow + 1; } @@ -138,6 +149,9 @@ private int outputHelp(Sheet worksheet, Workbook workbook, Table table, int star private int outputDataHeadings(Sheet worksheet, Workbook workbook, Table table, int rowIndex, Map helpTextRowNumbers) { int columnIndex = 0; for (Column column : table.columns()) { + if ("id".equals(column.getName()) || "version".equals(column.getName())) { + continue; + } if (columnIndex >= MAX_EXCEL_COLUMNS) { break; } @@ -158,16 +172,18 @@ private int outputExampleData(Integer numberOfExamples, Sheet worksheet, Workboo int currentRow = startRow; int written = 0; for (Record record : records) { - if (numberOfExamples != null && written >= numberOfExamples) { - break; + if (currentRow >= MAX_EXCEL_ROWS) { + continue; } - if (currentRow >= MAX_EXCEL_ROWS - 1) { - throw new RowLimitExceededException("Output for table '" + table.getName() + "' exceeds the maximum number of rows (" + MAX_EXCEL_ROWS + ") in an Excel worksheet. It will be truncated."); + if (numberOfExamples != null && written >= numberOfExamples) { + continue; } int columnIndex = 0; for (Column column : table.columns()) { + if ("id".equals(column.getName()) || "version".equals(column.getName())) { + continue; + } if (columnIndex >= MAX_EXCEL_COLUMNS) { - writeValue(worksheet, currentRow, columnIndex, "[TRUNCATED]", getStandardFormat(workbook)); break; } writeColumnValue(worksheet, workbook, currentRow, columnIndex, column, record); @@ -176,40 +192,57 @@ private int outputExampleData(Integer numberOfExamples, Sheet worksheet, Workboo currentRow++; written++; } + if (currentRow >= MAX_EXCEL_ROWS) { + throw new RowLimitExceededException("Output for table '" + table.getName() + "' exceeds the maximum number of rows (" + MAX_EXCEL_ROWS + ") in an Excel worksheet. It will be truncated."); + } return currentRow + 1; } private void writeColumnValue(Sheet worksheet, Workbook workbook, int rowIndex, int columnIndex, Column column, Record record) { - try { - switch (column.getType()) { - case STRING: - case CLOB: - String text = record.getString(column.getName()); - if (text == null) { - text = ""; + CellStyle style = getStandardFormat(workbook); + switch (column.getType()) { + case STRING: + writeStringCell(worksheet, rowIndex, columnIndex, record.getString(column.getName()), style); + break; + case DECIMAL: + BigDecimal decimalValue = record.getBigDecimal(column.getName()); + try { + if (decimalValue == null) { + createBlankCell(worksheet, rowIndex, columnIndex, style); + } else { + writeNumericCell(worksheet, rowIndex, columnIndex, decimalValue.doubleValue(), style); } - if (text.length() > MAX_CELL_CHARACTERS) { - text = text.substring(0, MAX_CELL_CHARACTERS); + } catch (Exception e) { + throw new UnsupportedOperationException("Cannot generate Excel cell (parseDouble) for data [" + decimalValue + "]" + unsupportedOperationExceptionMessageSuffix(column, worksheet), e); + } + break; + case BIG_INTEGER: + case INTEGER: + Long longValue = record.getLong(column.getName()); + try { + if (longValue == null) { + createBlankCell(worksheet, rowIndex, columnIndex, style); + } else { + writeNumericCell(worksheet, rowIndex, columnIndex, longValue.doubleValue(), style); } - writeValue(worksheet, rowIndex, columnIndex, text, getStandardFormat(workbook)); - break; - case DECIMAL: - BigDecimal decimal = record.getBigDecimal(column.getName()); - writeValue(worksheet, rowIndex, columnIndex, decimal == null ? "" : decimal.toString(), getStandardFormat(workbook)); - break; - case BIG_INTEGER: - case INTEGER: - Long longValue = record.getLong(column.getName()); - writeValue(worksheet, rowIndex, columnIndex, longValue == null ? "0" : longValue.toString(), getStandardFormat(workbook)); - break; - default: - throw new UnsupportedOperationException("Unsupported data type " + column.getType() + unsupportedOperationExceptionMessageSuffix(column, worksheet)); - } - } catch (RuntimeException e) { - String message = column.getType() == CLOB - ? "Cannot generate Excel cell for CLOB data" + unsupportedOperationExceptionMessageSuffix(column, worksheet) - : "Cannot generate Excel cell for data" + unsupportedOperationExceptionMessageSuffix(column, worksheet); - throw new UnsupportedOperationException(message, e); + } catch (Exception e) { + throw new UnsupportedOperationException("Cannot generate Excel cell (parseInt) for data [" + longValue + "]" + unsupportedOperationExceptionMessageSuffix(column, worksheet), e); + } + break; + case CLOB: + try { + String stringValue = record.getString(column.getName()); + if (stringValue == null) { + createBlankCell(worksheet, rowIndex, columnIndex, style); + } else { + writeStringCell(worksheet, rowIndex, columnIndex, StringUtils.substring(stringValue, 0, MAX_CELL_CHARACTERS), style); + } + } catch (Exception e) { + throw new UnsupportedOperationException("Cannot generate Excel cell for CLOB data" + unsupportedOperationExceptionMessageSuffix(column, worksheet), e); + } + break; + default: + throw new UnsupportedOperationException("Cannot output data type [" + column.getType() + "] to a spreadsheet"); } } @@ -221,6 +254,27 @@ private Cell writeValue(Sheet sheet, int rowIndex, int columnIndex, String value return cell; } + private void writeStringCell(Sheet sheet, int rowIndex, int columnIndex, String value, CellStyle style) { + if (value == null) { + createBlankCell(sheet, rowIndex, columnIndex, style); + } else { + writeValue(sheet, rowIndex, columnIndex, value, style); + } + } + + private void writeNumericCell(Sheet sheet, int rowIndex, int columnIndex, double value, CellStyle style) { + Row row = getOrCreateRow(sheet, rowIndex); + Cell cell = row.createCell(columnIndex, CellType.NUMERIC); + cell.setCellValue(value); + cell.setCellStyle(style); + } + + private void createBlankCell(Sheet sheet, int rowIndex, int columnIndex, CellStyle style) { + Row row = getOrCreateRow(sheet, rowIndex); + Cell cell = row.createCell(columnIndex, CellType.BLANK); + cell.setCellStyle(style); + } + private void createTitle(Sheet sheet, Workbook workbook, String title, String fileName) { Cell titleCell = writeValue(sheet, 0, 0, title, titleStyle(workbook)); titleCell.setCellStyle(titleStyle(workbook)); @@ -235,26 +289,15 @@ private void createTitle(Sheet sheet, Workbook workbook, String title, String fi } private CellStyle titleStyle(Workbook workbook) { - CellStyle style = workbook.createCellStyle(); - Font font = workbook.createFont(); - font.setBold(true); - font.setFontHeightInPoints((short) 16); - style.setFont(font); - return style; + return getStyles(workbook).titleStyle; } private CellStyle hiddenFileNameStyle(Workbook workbook) { - CellStyle style = workbook.createCellStyle(); - Font font = workbook.createFont(); - font.setColor(HSSFColorPredefined.WHITE.getIndex()); - style.setFont(font); - return style; + return getStyles(workbook).hiddenFileNameStyle; } private CellStyle copyrightStyle(Workbook workbook) { - CellStyle style = workbook.createCellStyle(); - style.setAlignment(HorizontalAlignment.RIGHT); - return style; + return getStyles(workbook).copyrightStyle; } private String spreadsheetifyName(String name) { @@ -262,35 +305,38 @@ private String spreadsheetifyName(String name) { } private CellStyle getStandardFormat(Workbook workbook) { - CellStyle style = workbook.createCellStyle(); - style.setVerticalAlignment(VerticalAlignment.TOP); - Font font = workbook.createFont(); - font.setFontHeightInPoints((short) 8); - style.setFont(font); - return style; + return getStyles(workbook).standardFormat; + } + + private CellStyle getWrappedFormat(Workbook workbook) { + return getStyles(workbook).wrappedFormat; } private CellStyle getBoldFormat(Workbook workbook) { - CellStyle style = workbook.createCellStyle(); - style.setVerticalAlignment(VerticalAlignment.TOP); - Font font = workbook.createFont(); - font.setBold(true); - font.setFontHeightInPoints((short) 8); - style.setFont(font); - return style; + return getStyles(workbook).boldFormat; } private CellStyle getBoldHeadingFormat(Workbook workbook) { - CellStyle style = getBoldFormat(workbook); - style.setBorderBottom(BorderStyle.MEDIUM); - style.setVerticalAlignment(VerticalAlignment.CENTER); - style.setFillForegroundColor(HSSFColorPredefined.GREY_25_PERCENT.getIndex()); - style.setFillPattern(FillPatternType.SOLID_FOREGROUND); - return style; + return getStyles(workbook).boldHeadingFormat; + } + + + private CellStyles getStyles(Workbook workbook) { + CellStyles styles = styleCache.get(workbook); + if (styles == null) { + styles = new CellStyles(workbook); + styleCache.put(workbook, styles); + } + return styles; } boolean tableHasUnsupportedColumns(Table table) { - return Iterables.any(table.columns(), column -> !SUPPORTED_DATA_TYPES.contains(column.getType())); + return Iterables.any(table.columns(), new Predicate() { + @Override + public boolean apply(Column column) { + return !SUPPORTED_DATA_TYPES.contains(column.getType()); + } + }); } private Row getOrCreateRow(Sheet sheet, int rowIndex) { @@ -302,6 +348,60 @@ private String unsupportedOperationExceptionMessageSuffix(Column column, Sheet w return " in column [" + column.getName() + "] of table [" + worksheet.getSheetName() + "]"; } + private static final class CellStyles { + private final CellStyle titleStyle; + private final CellStyle hiddenFileNameStyle; + private final CellStyle copyrightStyle; + private final CellStyle standardFormat; + private final CellStyle wrappedFormat; + private final CellStyle boldFormat; + private final CellStyle boldHeadingFormat; + + private CellStyles(Workbook workbook) { + Font standardFont = workbook.createFont(); + standardFont.setFontHeightInPoints((short) 8); + + Font boldFont = workbook.createFont(); + boldFont.setBold(true); + boldFont.setFontHeightInPoints((short) 8); + + Font titleFont = workbook.createFont(); + titleFont.setBold(true); + titleFont.setFontHeightInPoints((short) 16); + + Font hiddenFileNameFont = workbook.createFont(); + hiddenFileNameFont.setColor(HSSFColorPredefined.WHITE.getIndex()); + + titleStyle = workbook.createCellStyle(); + titleStyle.setFont(titleFont); + + hiddenFileNameStyle = workbook.createCellStyle(); + hiddenFileNameStyle.setFont(hiddenFileNameFont); + + copyrightStyle = workbook.createCellStyle(); + copyrightStyle.setAlignment(HorizontalAlignment.RIGHT); + + standardFormat = workbook.createCellStyle(); + standardFormat.setVerticalAlignment(VerticalAlignment.TOP); + standardFormat.setFont(standardFont); + + wrappedFormat = workbook.createCellStyle(); + wrappedFormat.cloneStyleFrom(standardFormat); + wrappedFormat.setWrapText(true); + + boldFormat = workbook.createCellStyle(); + boldFormat.setVerticalAlignment(VerticalAlignment.TOP); + boldFormat.setFont(boldFont); + + boldHeadingFormat = workbook.createCellStyle(); + boldHeadingFormat.cloneStyleFrom(boldFormat); + boldHeadingFormat.setBorderBottom(BorderStyle.MEDIUM); + boldHeadingFormat.setVerticalAlignment(VerticalAlignment.CENTER); + boldHeadingFormat.setFillForegroundColor(HSSFColorPredefined.GREY_25_PERCENT.getIndex()); + boldHeadingFormat.setFillPattern(FillPatternType.SOLID_FOREGROUND); + } + } + private static class RowLimitExceededException extends RuntimeException { RowLimitExceededException(String message) { super(message); diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java index 76003012f..d2285b858 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java @@ -26,6 +26,7 @@ import static org.mockito.Mockito.when; import java.io.OutputStream; +import java.util.Map; import java.util.Optional; import org.alfasoftware.morf.dataset.Record; @@ -38,14 +39,14 @@ public class TestSpreadsheetDataSetConsumer { - private static final ImmutableList NO_RECORDS = ImmutableList.of(); + private static final ImmutableList NO_RECORDS = ImmutableList.of(); @Test public void testIgnoreTable() { final MockTableOutputter outputter = new MockTableOutputter(); final SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( mock(OutputStream.class), - Optional.of(ImmutableMap.of("COMPANY", 5)), + Optional.>of(ImmutableMap.of("COMPANY", 5)), outputter); consumer.table(table("NotCompany"), NO_RECORDS); @@ -57,7 +58,7 @@ public void testUnsupportedColumns() { TableOutputter outputter = mock(TableOutputter.class); final SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( mock(OutputStream.class), - Optional.empty(), + Optional.>empty(), outputter); Table one = table("one"); @@ -77,13 +78,13 @@ public void testIncludeTableWithSpecificRowCount() { final MockTableOutputter outputter = new MockTableOutputter(); SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( mock(OutputStream.class), - Optional.of(ImmutableMap.of("COMPANY", 5)), + Optional.>of(ImmutableMap.of("COMPANY", 5)), outputter); consumer.table(table("Company"), NO_RECORDS); assertEquals("Table passed through for output", "Company", outputter.tableReceived); - assertEquals("Number of rows desired", 5, outputter.rowCountReceived); + assertEquals("Number of rows desired", Integer.valueOf(5), outputter.rowCountReceived); } private static class MockTableOutputter extends TableOutputter { diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java index f68f93268..fe2d6888e 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java @@ -21,6 +21,7 @@ import static org.junit.Assert.fail; import java.io.InputStream; +import java.net.URISyntaxException; import java.util.Collection; import java.util.List; @@ -38,9 +39,11 @@ public class TestSpreadsheetDataSetProducer { /** * Ensure that the schema can be retrieved from a set of excel files + * + * @throws URISyntaxException ... */ @Test - public void testGetSchema() { + public void testGetSchema() throws URISyntaxException { final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); // Check that the table names were picked up correctly @@ -54,9 +57,10 @@ public void testGetSchema() { /** * Ensure that the rows can be retrieved from a set of excel files * + * @throws URISyntaxException ... */ @Test - public void testGetRecords() { + public void testGetRecords() throws URISyntaxException { final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); // Check that the table names were picked up correctly @@ -77,9 +81,10 @@ record = records.get(1); /** * Ensure that the generated schema behaves as expected * + * @throws URISyntaxException ... */ @Test - public void testGetSchemaMethods() { + public void testGetSchemaMethods() throws URISyntaxException { final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); try { // Getting the metadata of a table is an unsupported method of the @@ -105,6 +110,7 @@ public void testGetSchemaMethods() { private SpreadsheetDataSetProducer produceTestSpreadsheet() { InputStream testExcel = getClass().getResourceAsStream("TestSpreadsheetDataSetProducer.xls"); - return new SpreadsheetDataSetProducer(testExcel); + final SpreadsheetDataSetProducer producer = new SpreadsheetDataSetProducer(testExcel); + return producer; } } diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java index f281bd39c..1d5dfaf42 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java @@ -116,7 +116,7 @@ public void testColumnTruncation() { tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); sheet = workbook.getSheetAt(0); - assertEquals("[TRUNCATED]", value(12, 256)); + assertEquals("[TRUNCATED]", value(260, 13)); } @Test @@ -137,13 +137,17 @@ public void testRowTruncation() { public void testClobTruncation() { when(table.columns()).thenReturn(ImmutableList.of(column("Col1", DataType.CLOB))); Record record1 = mock(Record.class); - when(record1.getString("Col1")).thenReturn("X".repeat(35000)); + StringBuilder clobValue = new StringBuilder(); + for (int c = 0; c < 35000; c++) { + clobValue.append('X'); + } + when(record1.getString("Col1")).thenReturn(clobValue.toString()); tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); sheet = workbook.getSheetAt(0); + assertEquals(32767, value(8, 0).length()); assertEquals(32767, value(12, 0).length()); - assertEquals(32767, value(16, 0).length()); } @Test @@ -153,15 +157,19 @@ public void testUnsupportedOperationExceptionHandling() { when(table.columns()).thenReturn(ImmutableList.of(column("Col1", DataType.DECIMAL))); BigDecimal bigDecimal = mock(BigDecimal.class); when(record1.getBigDecimal("Col1")).thenReturn(bigDecimal); - when(bigDecimal.toString()).thenThrow(new RuntimeException("BAD BIG DECIMAL")); + when(bigDecimal.doubleValue()).thenThrow(new RuntimeException("BAD BIG DECIMAL")); try { tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); fail("UnsupportedOperationException should be thrown"); } catch (Exception e) { - assertTrue(e.getCause().getMessage().startsWith("Cannot generate Excel cell for data")); + Throwable t = e.getCause() != null ? e.getCause() : e; + assertTrue(t.getMessage().startsWith("Cannot generate Excel cell (parseDouble) for data")); } + workbook = new HSSFWorkbook(); + sheet = workbook.createSheet("dummy"); + when(table.columns()).thenReturn(ImmutableList.of(column("Col1", DataType.CLOB))); when(record1.getString("Col1")).thenThrow(new RuntimeException("BAD CLOB")); @@ -169,7 +177,16 @@ public void testUnsupportedOperationExceptionHandling() { tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); fail("UnsupportedOperationException should be thrown"); } catch (Exception e) { - assertTrue(e.getCause().getMessage().startsWith("Cannot generate Excel cell for CLOB data")); + Throwable t = e; + boolean matched = false; + while (t != null) { + if (t.getMessage() != null && t.getMessage().startsWith("Cannot generate Excel cell for CLOB data")) { + matched = true; + break; + } + t = t.getCause(); + } + assertTrue(matched); } } From 56ecc939f069a6bf7b6c06215b9072f405a75585 Mon Sep 17 00:00:00 2001 From: Jon Knight Date: Tue, 21 Apr 2026 13:31:38 +0100 Subject: [PATCH 4/8] SCA tidy up --- .../morf/excel/AdditionalSchemaData.java | 4 ++-- .../morf/excel/SpreadsheetDataSetConsumer.java | 2 +- .../morf/excel/SpreadsheetDataSetProducer.java | 2 +- .../org/alfasoftware/morf/excel/TableOutputter.java | 8 +------- .../morf/excel/TestSpreadsheetDataSetConsumer.java | 11 +++++------ .../morf/excel/TestSpreadsheetDataSetProducer.java | 13 ++++--------- .../alfasoftware/morf/excel/TestTableOutputter.java | 6 +----- 7 files changed, 15 insertions(+), 31 deletions(-) diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java index a360264ad..eecef5296 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/AdditionalSchemaData.java @@ -31,7 +31,7 @@ public interface AdditionalSchemaData { * @param columnName The column name. * @return The column documentation. */ - public String columnDocumentation(Table table, String columnName); + String columnDocumentation(Table table, String columnName); /** * Fetches the default value for a column. @@ -40,5 +40,5 @@ public interface AdditionalSchemaData { * @param columnName The column name. * @return The column's default value. */ - public String columnDefaultValue(Table table, String columnName); + String columnDefaultValue(Table table, String columnName); } diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java index 7ac67003d..defd22b8f 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java @@ -57,7 +57,7 @@ public class SpreadsheetDataSetConsumer implements DataSetConsumer { private final TableOutputter tableOutputter; public SpreadsheetDataSetConsumer(OutputStream documentOutputStream) { - this(documentOutputStream, Optional.>empty()); + this(documentOutputStream, Optional.empty()); } public SpreadsheetDataSetConsumer(OutputStream documentOutputStream, Optional> rowsPerTable) { diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java index 76f395af6..7c96068a7 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java @@ -205,7 +205,7 @@ private Record createRecord(final long id, final Map columnHead final Sheet sheet, final int rowIndex) { final int translationId; String translationValue = translationColumn == -1 ? "" : getCellContents(sheet, translationColumn, rowIndex); - if (translationColumn != -1 && translationValue.length() > 0) { + if (translationColumn != -1 && !translationValue.isEmpty()) { translationId = translations.size() + 1; translations.add(createTranslationRecord(translationId, translationValue)); } else { diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java index f054bff84..0364472da 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java @@ -52,7 +52,6 @@ import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.util.WorkbookUtil; -import com.google.common.base.Predicate; import com.google.common.collect.Iterables; import com.google.common.collect.Sets; @@ -331,12 +330,7 @@ private CellStyles getStyles(Workbook workbook) { } boolean tableHasUnsupportedColumns(Table table) { - return Iterables.any(table.columns(), new Predicate() { - @Override - public boolean apply(Column column) { - return !SUPPORTED_DATA_TYPES.contains(column.getType()); - } - }); + return Iterables.any(table.columns(), column -> !SUPPORTED_DATA_TYPES.contains(column.getType())); } private Row getOrCreateRow(Sheet sheet, int rowIndex) { diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java index d2285b858..76003012f 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java @@ -26,7 +26,6 @@ import static org.mockito.Mockito.when; import java.io.OutputStream; -import java.util.Map; import java.util.Optional; import org.alfasoftware.morf.dataset.Record; @@ -39,14 +38,14 @@ public class TestSpreadsheetDataSetConsumer { - private static final ImmutableList NO_RECORDS = ImmutableList.of(); + private static final ImmutableList NO_RECORDS = ImmutableList.of(); @Test public void testIgnoreTable() { final MockTableOutputter outputter = new MockTableOutputter(); final SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( mock(OutputStream.class), - Optional.>of(ImmutableMap.of("COMPANY", 5)), + Optional.of(ImmutableMap.of("COMPANY", 5)), outputter); consumer.table(table("NotCompany"), NO_RECORDS); @@ -58,7 +57,7 @@ public void testUnsupportedColumns() { TableOutputter outputter = mock(TableOutputter.class); final SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( mock(OutputStream.class), - Optional.>empty(), + Optional.empty(), outputter); Table one = table("one"); @@ -78,13 +77,13 @@ public void testIncludeTableWithSpecificRowCount() { final MockTableOutputter outputter = new MockTableOutputter(); SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( mock(OutputStream.class), - Optional.>of(ImmutableMap.of("COMPANY", 5)), + Optional.of(ImmutableMap.of("COMPANY", 5)), outputter); consumer.table(table("Company"), NO_RECORDS); assertEquals("Table passed through for output", "Company", outputter.tableReceived); - assertEquals("Number of rows desired", Integer.valueOf(5), outputter.rowCountReceived); + assertEquals("Number of rows desired", 5, outputter.rowCountReceived); } private static class MockTableOutputter extends TableOutputter { diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java index fe2d6888e..51eb9e19a 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java @@ -21,7 +21,6 @@ import static org.junit.Assert.fail; import java.io.InputStream; -import java.net.URISyntaxException; import java.util.Collection; import java.util.List; @@ -40,10 +39,9 @@ public class TestSpreadsheetDataSetProducer { /** * Ensure that the schema can be retrieved from a set of excel files * - * @throws URISyntaxException ... */ @Test - public void testGetSchema() throws URISyntaxException { + public void testGetSchema() { final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); // Check that the table names were picked up correctly @@ -57,10 +55,9 @@ public void testGetSchema() throws URISyntaxException { /** * Ensure that the rows can be retrieved from a set of excel files * - * @throws URISyntaxException ... */ @Test - public void testGetRecords() throws URISyntaxException { + public void testGetRecords() { final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); // Check that the table names were picked up correctly @@ -81,10 +78,9 @@ record = records.get(1); /** * Ensure that the generated schema behaves as expected * - * @throws URISyntaxException ... */ @Test - public void testGetSchemaMethods() throws URISyntaxException { + public void testGetSchemaMethods() { final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); try { // Getting the metadata of a table is an unsupported method of the @@ -110,7 +106,6 @@ public void testGetSchemaMethods() throws URISyntaxException { private SpreadsheetDataSetProducer produceTestSpreadsheet() { InputStream testExcel = getClass().getResourceAsStream("TestSpreadsheetDataSetProducer.xls"); - final SpreadsheetDataSetProducer producer = new SpreadsheetDataSetProducer(testExcel); - return producer; + return new SpreadsheetDataSetProducer(testExcel); } } diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java index 1d5dfaf42..ce7d67f89 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestTableOutputter.java @@ -137,11 +137,7 @@ public void testRowTruncation() { public void testClobTruncation() { when(table.columns()).thenReturn(ImmutableList.of(column("Col1", DataType.CLOB))); Record record1 = mock(Record.class); - StringBuilder clobValue = new StringBuilder(); - for (int c = 0; c < 35000; c++) { - clobValue.append('X'); - } - when(record1.getString("Col1")).thenReturn(clobValue.toString()); + when(record1.getString("Col1")).thenReturn("X".repeat(35000)); tableOutputter.table(1, workbook, table, ImmutableList.of(record1)); sheet = workbook.getSheetAt(0); From bdd9533263e6a7037885ffce3cc41d30fc78f341 Mon Sep 17 00:00:00 2001 From: Jon Knight Date: Tue, 21 Apr 2026 13:34:19 +0100 Subject: [PATCH 5/8] Add missing test resource file --- .../excel/TestSpreadsheetDataSetProducer.xls | Bin 0 -> 240128 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 morf-excel/src/test/resources/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.xls diff --git a/morf-excel/src/test/resources/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.xls b/morf-excel/src/test/resources/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.xls new file mode 100644 index 0000000000000000000000000000000000000000..23bdf9854de782c5bdfc25ca98aea156f1949c0f GIT binary patch literal 240128 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 cbjw Date: Tue, 21 Apr 2026 13:59:01 +0100 Subject: [PATCH 6/8] Add Log4j as a real dependency in the parent --- pom.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e7f30f638..3b73410a7 100644 --- a/pom.xml +++ b/pom.xml @@ -251,7 +251,13 @@ - + + + log4j + log4j + test + + From 764b76bc9962fd7df611b678cbbf13295f1dc97f Mon Sep 17 00:00:00 2001 From: Jon Knight Date: Tue, 21 Apr 2026 14:56:07 +0100 Subject: [PATCH 7/8] SonarQube fixes --- .../excel/SpreadsheetDataSetProducer.java | 22 +- .../morf/excel/TableOutputter.java | 159 +++++--- .../excel/TestSpreadsheetDataSetConsumer.java | 132 +++++- .../excel/TestSpreadsheetDataSetProducer.java | 376 ++++++++++++------ 4 files changed, 505 insertions(+), 184 deletions(-) diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java index 7c96068a7..dfd835cb5 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetProducer.java @@ -245,8 +245,26 @@ public Schema getSchema() { }; } - @Override public void open() { } - @Override public void close() { } + /** + * No-op. + * + *

This producer is eagerly initialised: the spreadsheet is fully parsed + * during construction. There are therefore no resources to open.

+ */ + @Override public void open() { + // Intentionally empty + } + + + /** + * No-op. + * + *

This producer does not hold any open resources after construction, + * so there is nothing to close.

+ */ + @Override public void close() { + // Intentionally empty + } @Override public Iterable records(String tableName) { return tables.get(tableName); } @Override public boolean isTableEmpty(String tableName) { return tables.get(tableName).isEmpty(); } diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java index 0364472da..b7cf9fe50 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/TableOutputter.java @@ -65,6 +65,8 @@ class TableOutputter { private static final int MAX_EXCEL_ROWS = 65536; private static final int MAX_EXCEL_COLUMNS = 256; private static final int MAX_CELL_CHARACTERS = 32767; + private static final String ID = "id"; + private static final String VERSION = "version"; private static final Set SUPPORTED_DATA_TYPES = Sets.immutableEnumSet(STRING, DECIMAL, BIG_INTEGER, INTEGER, CLOB); private final AdditionalSchemaData additionalSchemaData; @@ -120,13 +122,15 @@ public void table(int maxSampleRows, final Workbook workbook, final Table table, } } + + private int outputHelp(Sheet worksheet, Workbook workbook, Table table, int startRow, Map helpTextRowNumbers) { int currentRow = startRow; writeValue(worksheet, currentRow++, 0, "Column Descriptions", getBoldFormat(workbook)); int currentColumn = 0; for (Column column : table.columns()) { - if ("id".equals(column.getName()) || "version".equals(column.getName())) { + if (ID.equals(column.getName()) || VERSION.equals(column.getName())) { continue; } @@ -148,7 +152,7 @@ private int outputHelp(Sheet worksheet, Workbook workbook, Table table, int star private int outputDataHeadings(Sheet worksheet, Workbook workbook, Table table, int rowIndex, Map helpTextRowNumbers) { int columnIndex = 0; for (Column column : table.columns()) { - if ("id".equals(column.getName()) || "version".equals(column.getName())) { + if (ID.equals(column.getName()) || VERSION.equals(column.getName())) { continue; } if (columnIndex >= MAX_EXCEL_COLUMNS) { @@ -167,84 +171,126 @@ private int outputDataHeadings(Sheet worksheet, Workbook workbook, Table table, } private int outputExampleData(Integer numberOfExamples, Sheet worksheet, Workbook workbook, Table table, int startRow, - Iterable records) { + Iterable records) { int currentRow = startRow; int written = 0; + for (Record record : records) { - if (currentRow >= MAX_EXCEL_ROWS) { - continue; - } - if (numberOfExamples != null && written >= numberOfExamples) { - continue; - } - int columnIndex = 0; - for (Column column : table.columns()) { - if ("id".equals(column.getName()) || "version".equals(column.getName())) { - continue; - } - if (columnIndex >= MAX_EXCEL_COLUMNS) { - break; - } - writeColumnValue(worksheet, workbook, currentRow, columnIndex, column, record); - columnIndex++; + if (currentRow >= MAX_EXCEL_ROWS || hasWrittenRequestedExamples(numberOfExamples, written)) { + break; } + + outputExampleRow(worksheet, workbook, table, currentRow, record); currentRow++; written++; } + if (currentRow >= MAX_EXCEL_ROWS) { throw new RowLimitExceededException("Output for table '" + table.getName() + "' exceeds the maximum number of rows (" + MAX_EXCEL_ROWS + ") in an Excel worksheet. It will be truncated."); } return currentRow + 1; } + private boolean hasWrittenRequestedExamples(Integer numberOfExamples, int written) { + return numberOfExamples != null && written >= numberOfExamples; + } + + private void outputExampleRow(Sheet worksheet, Workbook workbook, Table table, int rowIndex, Record record) { + int columnIndex = 0; + + for (Column column : table.columns()) { + if (!isSystemColumn(column)) { + if (columnIndex >= MAX_EXCEL_COLUMNS) { + return; + } + writeColumnValue(worksheet, workbook, rowIndex, columnIndex, column, record); + columnIndex++; + } + } + } + + private boolean isSystemColumn(Column column) { + return ID.equals(column.getName()) || VERSION.equals(column.getName()); + } + private void writeColumnValue(Sheet worksheet, Workbook workbook, int rowIndex, int columnIndex, Column column, Record record) { CellStyle style = getStandardFormat(workbook); + switch (column.getType()) { case STRING: writeStringCell(worksheet, rowIndex, columnIndex, record.getString(column.getName()), style); - break; + return; + case DECIMAL: - BigDecimal decimalValue = record.getBigDecimal(column.getName()); - try { - if (decimalValue == null) { - createBlankCell(worksheet, rowIndex, columnIndex, style); - } else { - writeNumericCell(worksheet, rowIndex, columnIndex, decimalValue.doubleValue(), style); - } - } catch (Exception e) { - throw new UnsupportedOperationException("Cannot generate Excel cell (parseDouble) for data [" + decimalValue + "]" + unsupportedOperationExceptionMessageSuffix(column, worksheet), e); - } - break; + writeDecimalCell(worksheet, rowIndex, columnIndex, column, record, style); + return; + case BIG_INTEGER: case INTEGER: - Long longValue = record.getLong(column.getName()); - try { - if (longValue == null) { - createBlankCell(worksheet, rowIndex, columnIndex, style); - } else { - writeNumericCell(worksheet, rowIndex, columnIndex, longValue.doubleValue(), style); - } - } catch (Exception e) { - throw new UnsupportedOperationException("Cannot generate Excel cell (parseInt) for data [" + longValue + "]" + unsupportedOperationExceptionMessageSuffix(column, worksheet), e); - } - break; + writeIntegerCell(worksheet, rowIndex, columnIndex, column, record, style); + return; + case CLOB: - try { - String stringValue = record.getString(column.getName()); - if (stringValue == null) { - createBlankCell(worksheet, rowIndex, columnIndex, style); - } else { - writeStringCell(worksheet, rowIndex, columnIndex, StringUtils.substring(stringValue, 0, MAX_CELL_CHARACTERS), style); - } - } catch (Exception e) { - throw new UnsupportedOperationException("Cannot generate Excel cell for CLOB data" + unsupportedOperationExceptionMessageSuffix(column, worksheet), e); - } - break; + writeClobCell(worksheet, rowIndex, columnIndex, column, record, style); + return; + default: throw new UnsupportedOperationException("Cannot output data type [" + column.getType() + "] to a spreadsheet"); } } + private void writeDecimalCell(Sheet worksheet, int rowIndex, int columnIndex, Column column, Record record, CellStyle style) { + BigDecimal decimalValue = record.getBigDecimal(column.getName()); + try { + writeNullableNumericCell(worksheet, rowIndex, columnIndex, decimalValue, style); + } catch (Exception e) { + throw new UnsupportedOperationException( + "Cannot generate Excel cell (parseDouble) for data [" + decimalValue + "]" + + unsupportedOperationExceptionMessageSuffix(column, worksheet), + e); + } + } + + private void writeIntegerCell(Sheet worksheet, int rowIndex, int columnIndex, Column column, Record record, CellStyle style) { + Long longValue = record.getLong(column.getName()); + try { + writeNullableNumericCell(worksheet, rowIndex, columnIndex, longValue, style); + } catch (Exception e) { + throw new UnsupportedOperationException( + "Cannot generate Excel cell (parseInt) for data [" + longValue + "]" + + unsupportedOperationExceptionMessageSuffix(column, worksheet), + e); + } + } + + private void writeClobCell(Sheet worksheet, int rowIndex, int columnIndex, Column column, Record record, CellStyle style) { + try { + String stringValue = record.getString(column.getName()); + if (stringValue == null) { + createBlankCell(worksheet, rowIndex, columnIndex, style); + } else { + writeStringCell( + worksheet, + rowIndex, + columnIndex, + StringUtils.substring(stringValue, 0, MAX_CELL_CHARACTERS), + style); + } + } catch (Exception e) { + throw new UnsupportedOperationException( + "Cannot generate Excel cell for CLOB data" + unsupportedOperationExceptionMessageSuffix(column, worksheet), + e); + } + } + + private void writeNullableNumericCell(Sheet worksheet, int rowIndex, int columnIndex, Number value, CellStyle style) { + if (value == null) { + createBlankCell(worksheet, rowIndex, columnIndex, style); + } else { + writeNumericCell(worksheet, rowIndex, columnIndex, value.doubleValue(), style); + } + } + private Cell writeValue(Sheet sheet, int rowIndex, int columnIndex, String value, CellStyle style) { Row row = getOrCreateRow(sheet, rowIndex); Cell cell = row.createCell(columnIndex); @@ -321,12 +367,7 @@ private CellStyle getBoldHeadingFormat(Workbook workbook) { private CellStyles getStyles(Workbook workbook) { - CellStyles styles = styleCache.get(workbook); - if (styles == null) { - styles = new CellStyles(workbook); - styleCache.put(workbook, styles); - } - return styles; + return styleCache.computeIfAbsent(workbook, CellStyles::new); } boolean tableHasUnsupportedColumns(Table table) { diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java index 76003012f..41b4661e1 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetConsumer.java @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -12,12 +12,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.alfasoftware.morf.excel; import static org.alfasoftware.morf.metadata.SchemaUtils.table; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.mock; @@ -25,43 +26,55 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.OutputStream; import java.util.Optional; +import org.alfasoftware.morf.dataset.DataSetConsumer.CloseState; import org.alfasoftware.morf.dataset.Record; import org.alfasoftware.morf.metadata.Table; +import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.ss.util.WorkbookUtil; import org.junit.Test; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; + public class TestSpreadsheetDataSetConsumer { private static final ImmutableList NO_RECORDS = ImmutableList.of(); + @Test public void testIgnoreTable() { final MockTableOutputter outputter = new MockTableOutputter(); final SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( - mock(OutputStream.class), - Optional.of(ImmutableMap.of("COMPANY", 5)), - outputter); + mock(OutputStream.class), + Optional.of(ImmutableMap.of("COMPANY", 5)), + outputter); consumer.table(table("NotCompany"), NO_RECORDS); + assertNull("Table not passed through", outputter.tableReceived); } + @Test public void testUnsupportedColumns() { TableOutputter outputter = mock(TableOutputter.class); final SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( - mock(OutputStream.class), - Optional.empty(), - outputter); + mock(OutputStream.class), + Optional.empty(), + outputter); Table one = table("one"); Table two = table("two"); + when(outputter.tableHasUnsupportedColumns(one)).thenReturn(true); when(outputter.tableHasUnsupportedColumns(two)).thenReturn(false); @@ -72,13 +85,14 @@ public void testUnsupportedColumns() { verify(outputter, times(0)).table(nullable(Integer.class), nullable(Workbook.class), eq(one), eq(NO_RECORDS)); } + @Test public void testIncludeTableWithSpecificRowCount() { final MockTableOutputter outputter = new MockTableOutputter(); SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( - mock(OutputStream.class), - Optional.of(ImmutableMap.of("COMPANY", 5)), - outputter); + mock(OutputStream.class), + Optional.of(ImmutableMap.of("COMPANY", 5)), + outputter); consumer.table(table("Company"), NO_RECORDS); @@ -86,6 +100,85 @@ public void testIncludeTableWithSpecificRowCount() { assertEquals("Number of rows desired", 5, outputter.rowCountReceived); } + + @Test + public void testCloseCreatesIndexAndNavigationHyperlinks() throws Exception { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( + outputStream, + Optional.empty(), + new WritingTableOutputter()); + + consumer.open(); + consumer.table(table("Company"), NO_RECORDS); + consumer.close(CloseState.COMPLETE); + + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(outputStream.toByteArray()))) { + assertEquals("Index sheet should be first", "Index", workbook.getSheetAt(0).getSheetName()); + assertEquals("Workbook should contain index and table sheet", 2, workbook.getNumberOfSheets()); + + Row indexRow = workbook.getSheet("Index").getRow(2); + assertEquals("Table name should be written to index", "Company", indexRow.getCell(0).getStringCellValue()); + assertNotNull("Index entry should have a hyperlink", indexRow.getCell(0).getHyperlink()); + assertEquals("Index hyperlink should point to the table sheet", "'Company'!A1", indexRow.getCell(0).getHyperlink().getAddress()); + assertEquals("Filename should be copied from the table sheet", "Company file", indexRow.getCell(1).getStringCellValue()); + + Row backLinkRow = workbook.getSheet("Company").getRow(1); + assertEquals("Back-link text should be added to the table sheet", "Back to index", backLinkRow.getCell(0).getStringCellValue()); + assertNotNull("Back-link should have a hyperlink", backLinkRow.getCell(0).getHyperlink()); + assertEquals("Back-link should point back to the index row", "'Index'!A3", backLinkRow.getCell(0).getHyperlink().getAddress()); + } + } + + + @Test + public void testCloseWrapsIOException() { + SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( + new OutputStream() { + @Override + public void write(int b) throws IOException { + throw new IOException("boom"); + } + }, + Optional.empty(), + new WritingTableOutputter()); + + consumer.open(); + + try { + consumer.close(CloseState.COMPLETE); + } catch (RuntimeException e) { + assertEquals("Error closing writable workbook", e.getMessage()); + assertNotNull("Cause should be preserved", e.getCause()); + assertEquals("boom", e.getCause().getMessage()); + return; + } + + throw new AssertionError("Exception should have been thrown"); + } + + + @Test + public void testWritingTableOutputterUsesSafeSheetNames() throws Exception { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + SpreadsheetDataSetConsumer consumer = new SpreadsheetDataSetConsumer( + outputStream, + Optional.empty(), + new WritingTableOutputter()); + + consumer.open(); + consumer.table(table("Bad:/Name*[]?"), NO_RECORDS); + consumer.close(CloseState.COMPLETE); + + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(outputStream.toByteArray()))) { + String safeSheetName = WorkbookUtil.createSafeSheetName("Bad:/Name*[]?"); + assertNotNull("Unsafe table name should be converted to a valid sheet name", workbook.getSheet(safeSheetName)); + assertTrue("Original unsafe sheet name should not exist", + workbook.getSheet("Bad:/Name*[]?") == null); + } + } + + private static class MockTableOutputter extends TableOutputter { private String tableReceived; private Number rowCountReceived; @@ -95,9 +188,24 @@ private static class MockTableOutputter extends TableOutputter { } @Override - public void table(int maxRows, Workbook workbook, Table table, Iterable records) { + public void table(int maxRows, Workbook workbook, Table table, Iterable records) { tableReceived = table.getName(); rowCountReceived = maxRows; } } + + + private static final class WritingTableOutputter extends TableOutputter { + + WritingTableOutputter() { + super(new DefaultAdditionalSchemaDataImpl()); + } + + @Override + public void table(int maxRows, Workbook workbook, Table table, Iterable records) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(table.getName())); + sheet.createRow(0).createCell(0).setCellValue(table.getName()); + sheet.createRow(1).createCell(1).setCellValue(table.getName() + " file"); + } + } } diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java index 51eb9e19a..d1f00df5a 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java @@ -1,111 +1,265 @@ -/* Copyright 2017 Alfa Financial Software - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.alfasoftware.morf.excel; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import java.io.InputStream; -import java.util.Collection; -import java.util.List; - -import org.alfasoftware.morf.dataset.Record; -import org.junit.Test; - -import com.google.common.collect.Lists; - -/** - * Ensure that the {@link SpreadsheetDataSetProducer} works correctly. - * - * @author Copyright (c) Alfa Financial Software 2010 - */ -public class TestSpreadsheetDataSetProducer { - - /** - * Ensure that the schema can be retrieved from a set of excel files - * - */ - @Test - public void testGetSchema() { - final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); - - // Check that the table names were picked up correctly - Collection tableNames = producer.getSchema().tableNames(); - assertEquals("Number of tables found [" + tableNames + "]", 12, tableNames.size()); - assertTrue("Tables correctly populated [" + tableNames + "]", tableNames.contains("AssetType")); - assertTrue("Tables correctly populated [" + tableNames + "]", tableNames.contains("Allowance")); - } - - - /** - * Ensure that the rows can be retrieved from a set of excel files - * - */ - @Test - public void testGetRecords() { - final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); - - // Check that the table names were picked up correctly - List records = Lists.newArrayList(producer.records("UsageMeterType")); - assertEquals("Number of rows [" + records + "]", 2, records.size()); - Record record = records.get(0); - assertEquals("Usage Meter", "KM", record.getString("usageMeter")); - assertEquals("Description", "Kilometers", record.getString("description")); - assertEquals("Usage Meter Type", "M", record.getString("usageMeterType")); - - record = records.get(1); - assertEquals("Usage Meter", "HOUR", record.getString("usageMeter")); - assertEquals("Description", "Hours", record.getString("description")); - assertEquals("Usage Meter Type", "", record.getString("usageMeterType")); - } - - - /** - * Ensure that the generated schema behaves as expected - * - */ - @Test - public void testGetSchemaMethods() { - final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); - try { - // Getting the metadata of a table is an unsupported method of the - // minimal schema - producer.getSchema().getTable("Name"); - fail("Exception should have been thrown"); - } catch (UnsupportedOperationException e) { - // This is expected - } - - try { - // Getting all tables is an unsupported method of the minimal schema - producer.getSchema().tables(); - fail("Exception should have been thrown"); - } catch (UnsupportedOperationException e) { - // This is expected - } - - assertFalse("Schema is not empty", producer.getSchema().isEmptyDatabase()); - assertTrue("UsageMeterType table exists", producer.getSchema().tableExists("UsageMeterType")); - } - - - private SpreadsheetDataSetProducer produceTestSpreadsheet() { - InputStream testExcel = getClass().getResourceAsStream("TestSpreadsheetDataSetProducer.xls"); - return new SpreadsheetDataSetProducer(testExcel); - } -} +/* Copyright 2017 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.alfasoftware.morf.excel; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; +import java.util.List; + +import org.alfasoftware.morf.dataset.Record; +import org.apache.poi.common.usermodel.HyperlinkType; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Workbook; +import org.junit.Test; + +import com.google.common.collect.Lists; + + +/** + * Ensure that the {@link SpreadsheetDataSetProducer} works correctly. + * + * @author Copyright (c) Alfa Financial Software 2010 + */ +public class TestSpreadsheetDataSetProducer { + + /** + * Ensure that the schema can be retrieved from a set of excel files + */ + @Test + public void testGetSchema() { + final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); + + // Check that the table names were picked up correctly + Collection tableNames = producer.getSchema().tableNames(); + assertEquals("Number of tables found [" + tableNames + "]", 12, tableNames.size()); + assertTrue("Tables correctly populated [" + tableNames + "]", tableNames.contains("AssetType")); + assertTrue("Tables correctly populated [" + tableNames + "]", tableNames.contains("Allowance")); + } + + + /** + * Ensure that the rows can be retrieved from a set of excel files + */ + @Test + public void testGetRecords() { + final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); + + // Check that the table names were picked up correctly + List records = Lists.newArrayList(producer.records("UsageMeterType")); + assertEquals("Number of rows [" + records + "]", 2, records.size()); + + Record record = records.get(0); + assertEquals("Usage Meter", "KM", record.getString("usageMeter")); + assertEquals("Description", "Kilometers", record.getString("description")); + assertEquals("Usage Meter Type", "M", record.getString("usageMeterType")); + + record = records.get(1); + assertEquals("Usage Meter", "HOUR", record.getString("usageMeter")); + assertEquals("Description", "Hours", record.getString("description")); + assertEquals("Usage Meter Type", "", record.getString("usageMeterType")); + } + + + /** + * Ensure that the generated schema behaves as expected + */ + @Test + public void testGetSchemaMethods() { + final SpreadsheetDataSetProducer producer = produceTestSpreadsheet(); + + try { + // Getting the metadata of a table is an unsupported method of the + // minimal schema + producer.getSchema().getTable("Name"); + fail("Exception should have been thrown"); + } catch (UnsupportedOperationException e) { + // Expected + } + + try { + // Getting all tables is an unsupported method of the minimal schema + producer.getSchema().tables(); + fail("Exception should have been thrown"); + } catch (UnsupportedOperationException e) { + // Expected + } + + assertFalse("Schema is not empty", producer.getSchema().isEmptyDatabase()); + assertTrue("UsageMeterType table exists", producer.getSchema().tableExists("UsageMeterType")); + } + + + @Test + public void testNullInputStreamRejected() { + try { + new SpreadsheetDataSetProducer((InputStream) null); + fail("Exception should have been thrown"); + } catch (IllegalArgumentException e) { + assertEquals("Spreadsheet input stream was null", e.getMessage()); + } + } + + + @Test + public void testUnquotedHyperlinkAddressIsParsedAndBlankRowStopsRecords() throws Exception { + SpreadsheetDataSetProducer producer = new SpreadsheetDataSetProducer(new ByteArrayInputStream( + workbookBytes(workbookWithSingleTable("UsageMeterType", "UsageMeterType!A1", true, true, true, false, false)))); + + List records = Lists.newArrayList(producer.records("UsageMeterType")); + assertEquals("One populated data row should be parsed", 1, records.size()); + assertEquals("KM", records.get(0).getString("usageMeter")); + assertEquals("Kilometers", records.get(0).getString("description")); + assertEquals("1", records.get(0).getString("id")); + assertEquals("1", records.get(0).getString("translationId")); + } + + + @Test + public void testMissingHyperlinkInIndexIsWrapped() throws Exception { + try { + new SpreadsheetDataSetProducer(new ByteArrayInputStream( + workbookBytes(workbookWithSingleTable("UsageMeterType", null, true, false, false, false, false)))); + fail("Exception should have been thrown"); + } catch (RuntimeException e) { + assertEquals("Failed to parse spreadsheet", e.getMessage()); + assertTrue("Root cause should mention the missing hyperlink", + findMessage(e, "Failed to find hyperlink in index sheet at [0,2]")); + } + } + + + @Test + public void testMissingWorksheetIsWrapped() throws Exception { + try { + new SpreadsheetDataSetProducer(new ByteArrayInputStream( + workbookBytes(workbookWithSingleTable("UsageMeterType", "'MissingSheet'!A1", false, true, false, false, false)))); + fail("Exception should have been thrown"); + } catch (RuntimeException e) { + assertEquals("Failed to parse spreadsheet", e.getMessage()); + assertTrue("Root cause should mention the missing worksheet", + findMessage(e, "Failed to find worksheet with name [MissingSheet]")); + } + } + + + @Test + public void testMissingHeaderRowIsWrapped() throws Exception { + try { + new SpreadsheetDataSetProducer(new ByteArrayInputStream( + workbookBytes(workbookWithSingleTable("UsageMeterType", "'UsageMeterType'!A1", true, true, false, true, false)))); + fail("Exception should have been thrown"); + } catch (RuntimeException e) { + assertEquals("Failed to parse spreadsheet", e.getMessage()); + assertTrue("Root cause should mention the missing header row", + findMessage(e, "Could not find header row in worksheet [UsageMeterType]")); + } + } + + + @Test + public void testNoTranslationColumnStillProducesRecords() throws Exception { + SpreadsheetDataSetProducer producer = new SpreadsheetDataSetProducer(new ByteArrayInputStream( + workbookBytes(workbookWithSingleTable("UsageMeterType", "'UsageMeterType'!A1", true, true, false, false, true)))); + + List records = Lists.newArrayList(producer.records("UsageMeterType")); + assertEquals("One populated data row should be parsed", 1, records.size()); + assertEquals("KM", records.get(0).getString("usageMeter")); + assertEquals("Kilometers", records.get(0).getString("description")); + } + + + private SpreadsheetDataSetProducer produceTestSpreadsheet() { + InputStream testExcel = getClass().getResourceAsStream("TestSpreadsheetDataSetProducer.xls"); + return new SpreadsheetDataSetProducer(testExcel); + } + + + private Workbook workbookWithSingleTable(String tableName, String hyperlinkAddress, boolean createWorksheet, + boolean indexHasHyperlink, boolean useTranslationColumn, boolean omitHeaderHyperlink, boolean noTranslationColumn) { + Workbook workbook = new HSSFWorkbook(); + + org.apache.poi.ss.usermodel.Sheet index = workbook.createSheet("Index"); + Row indexRow = index.createRow(2); + if (indexHasHyperlink) { + indexRow.createCell(0).setCellValue(tableName); + indexRow.getCell(0).setHyperlink(workbook.getCreationHelper().createHyperlink(HyperlinkType.DOCUMENT)); + indexRow.getCell(0).getHyperlink().setAddress(hyperlinkAddress); + } + indexRow.createCell(1).setCellValue(tableName); + + if (createWorksheet) { + org.apache.poi.ss.usermodel.Sheet tableSheet = workbook.createSheet(tableName); + tableSheet.createRow(0).createCell(0).setCellValue(tableName); + tableSheet.createRow(1).createCell(0).setCellValue("Parameters to Set Up"); + + Row headerRow = tableSheet.createRow(2); + if (!omitHeaderHyperlink) { + headerRow.createCell(0).setHyperlink(workbook.getCreationHelper().createHyperlink(HyperlinkType.DOCUMENT)); + headerRow.getCell(0).getHyperlink().setAddress("'Index'!A1"); + } else { + headerRow.createCell(0); + } + headerRow.getCell(0).setCellValue("Usage Meter"); + headerRow.createCell(1).setCellValue("Description"); + if (useTranslationColumn && !noTranslationColumn) { + headerRow.createCell(2).setCellValue(""); + headerRow.createCell(3).setCellValue("Translation"); + } + + Row dataRow = tableSheet.createRow(3); + dataRow.createCell(0).setCellValue("KM"); + dataRow.createCell(1).setCellValue("Kilometers"); + if (useTranslationColumn && !noTranslationColumn) { + dataRow.createCell(3).setCellValue("M"); + } + + if (useTranslationColumn && !noTranslationColumn) { + tableSheet.createRow(5); + } + } + + return workbook; + } + + + private byte[] workbookBytes(Workbook workbook) throws IOException { + try (Workbook closeableWorkbook = workbook; + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + closeableWorkbook.write(outputStream); + return outputStream.toByteArray(); + } + } + + + private boolean findMessage(Throwable throwable, String expectedMessage) { + Throwable current = throwable; + while (current != null) { + if (expectedMessage.equals(current.getMessage())) { + return true; + } + current = current.getCause(); + } + return false; + } +} From e5d6261126f2e47af3936f20be079e7112319690 Mon Sep 17 00:00:00 2001 From: Jon Knight Date: Tue, 21 Apr 2026 17:06:28 +0100 Subject: [PATCH 8/8] Hyperlink formatting --- .../excel/SpreadsheetDataSetConsumer.java | 60 ++++++++++++++----- .../morf/excel/TableOutputter.java | 15 +++++ .../excel/TestSpreadsheetDataSetConsumer.java | 8 +-- .../excel/TestSpreadsheetDataSetProducer.java | 31 +++++----- 4 files changed, 77 insertions(+), 37 deletions(-) diff --git a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java index defd22b8f..bf7bf985e 100644 --- a/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java +++ b/morf-excel/src/main/java/org/alfasoftware/morf/excel/SpreadsheetDataSetConsumer.java @@ -34,6 +34,7 @@ import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.Font; import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; @@ -53,11 +54,12 @@ public class SpreadsheetDataSetConsumer implements DataSetConsumer { private final OutputStream documentOutputStream; private Workbook workbook; + private ConsumerStyles consumerStyles; private final Optional> rowsPerTable; private final TableOutputter tableOutputter; public SpreadsheetDataSetConsumer(OutputStream documentOutputStream) { - this(documentOutputStream, Optional.empty()); + this(documentOutputStream, Optional.>empty()); } public SpreadsheetDataSetConsumer(OutputStream documentOutputStream, Optional> rowsPerTable) { @@ -79,6 +81,7 @@ public SpreadsheetDataSetConsumer(OutputStream documentOutputStream, Optional records) { tableReceived = table.getName(); rowCountReceived = maxRows; } diff --git a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java index d1f00df5a..7c85bac86 100644 --- a/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java +++ b/morf-excel/src/test/java/org/alfasoftware/morf/excel/TestSpreadsheetDataSetProducer.java @@ -16,6 +16,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -134,7 +135,6 @@ public void testUnquotedHyperlinkAddressIsParsedAndBlankRowStopsRecords() throws assertEquals("1", records.get(0).getString("translationId")); } - @Test public void testMissingHyperlinkInIndexIsWrapped() throws Exception { try { @@ -148,7 +148,6 @@ public void testMissingHyperlinkInIndexIsWrapped() throws Exception { } } - @Test public void testMissingWorksheetIsWrapped() throws Exception { try { @@ -162,7 +161,6 @@ public void testMissingWorksheetIsWrapped() throws Exception { } } - @Test public void testMissingHeaderRowIsWrapped() throws Exception { try { @@ -176,7 +174,6 @@ public void testMissingHeaderRowIsWrapped() throws Exception { } } - @Test public void testNoTranslationColumnStillProducesRecords() throws Exception { SpreadsheetDataSetProducer producer = new SpreadsheetDataSetProducer(new ByteArrayInputStream( @@ -186,15 +183,24 @@ public void testNoTranslationColumnStillProducesRecords() throws Exception { assertEquals("One populated data row should be parsed", 1, records.size()); assertEquals("KM", records.get(0).getString("usageMeter")); assertEquals("Kilometers", records.get(0).getString("description")); + assertEquals("0", String.valueOf(records.get(0).getInteger("translationId"))); } + @Test + public void testQuotedHyperlinkAddressResolvesWorksheet() throws Exception { + SpreadsheetDataSetProducer producer = new SpreadsheetDataSetProducer(new ByteArrayInputStream( + workbookBytes(workbookWithSingleTable("UsageMeterType", "'UsageMeterType'!A1", true, true, false, false, true)))); + + List records = Lists.newArrayList(producer.records("UsageMeterType")); + assertEquals(1, records.size()); + assertNotNull(records.get(0)); + } private SpreadsheetDataSetProducer produceTestSpreadsheet() { InputStream testExcel = getClass().getResourceAsStream("TestSpreadsheetDataSetProducer.xls"); return new SpreadsheetDataSetProducer(testExcel); } - private Workbook workbookWithSingleTable(String tableName, String hyperlinkAddress, boolean createWorksheet, boolean indexHasHyperlink, boolean useTranslationColumn, boolean omitHeaderHyperlink, boolean noTranslationColumn) { Workbook workbook = new HSSFWorkbook(); @@ -214,13 +220,11 @@ private Workbook workbookWithSingleTable(String tableName, String hyperlinkAddre tableSheet.createRow(1).createCell(0).setCellValue("Parameters to Set Up"); Row headerRow = tableSheet.createRow(2); + headerRow.createCell(0).setCellValue("Usage Meter"); if (!omitHeaderHyperlink) { - headerRow.createCell(0).setHyperlink(workbook.getCreationHelper().createHyperlink(HyperlinkType.DOCUMENT)); + headerRow.getCell(0).setHyperlink(workbook.getCreationHelper().createHyperlink(HyperlinkType.DOCUMENT)); headerRow.getCell(0).getHyperlink().setAddress("'Index'!A1"); - } else { - headerRow.createCell(0); } - headerRow.getCell(0).setCellValue("Usage Meter"); headerRow.createCell(1).setCellValue("Description"); if (useTranslationColumn && !noTranslationColumn) { headerRow.createCell(2).setCellValue(""); @@ -234,24 +238,19 @@ private Workbook workbookWithSingleTable(String tableName, String hyperlinkAddre dataRow.createCell(3).setCellValue("M"); } - if (useTranslationColumn && !noTranslationColumn) { - tableSheet.createRow(5); - } + tableSheet.createRow(4); } return workbook; } - private byte[] workbookBytes(Workbook workbook) throws IOException { - try (Workbook closeableWorkbook = workbook; - ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + try (Workbook closeableWorkbook = workbook; ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { closeableWorkbook.write(outputStream); return outputStream.toByteArray(); } } - private boolean findMessage(Throwable throwable, String expectedMessage) { Throwable current = throwable; while (current != null) {