ExamplesTable.java
package org.jbehave.core.model;
import static java.lang.Boolean.parseBoolean;
import static java.util.regex.Pattern.DOTALL;
import static java.util.stream.Collectors.toList;
import static org.apache.commons.lang3.Validate.isTrue;
import static org.apache.commons.lang3.Validate.notBlank;
import java.io.PrintStream;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.jbehave.core.configuration.Keywords;
import org.jbehave.core.i18n.LocalizedKeywords;
import org.jbehave.core.io.LoadFromClasspath;
import org.jbehave.core.model.TableTransformers.TableTransformer;
import org.jbehave.core.steps.ChainedRow;
import org.jbehave.core.steps.ConvertedParameters;
import org.jbehave.core.steps.ParameterControls;
import org.jbehave.core.steps.ParameterConverters;
import org.jbehave.core.steps.Parameters;
import org.jbehave.core.steps.Row;
/**
* Represents a tabular structure that holds rows of example data for parameters named via the column headers:
* <pre>
* |header 1|header 2| .... |header n|
* |value 11|value 12| .... |value 1n|
* ...
* |value m1|value m2| .... |value mn|
* </pre>
*
* <p>Different header and value column separators can be specified to replace the default separator "|":
* <pre>
* !!header 1!!header 2!! .... !!header n!!
* !value 11!value 12! .... !value 1n!
* ...
* !value m1!value m2| .... !value mn!
* </pre>
* </p>
*
* <p>Rows starting with an ignorable separator are allowed and ignored:
* <pre>
* |header 1|header 2| .... |header n|
* |-- A commented row --|
* |value 11|value 12| .... |value 1n|
* ...
* |-- Another commented row --|
* |value m1|value m2| .... |value mn|
* </pre>
* </p>
*
* <p>Ignorable separator is configurable and defaults to "|--".</p>
* <p>The separators are also configurable via inlined properties:
* <pre>
* {ignorableSeparator=!--,headerSeparator=!,valueSeparator=!}
* !header 1!header 2! .... !header n!
* !-- A commented row --!
* !value 11!value 12! .... !value 1n!
* ...
* !-- Another commented row --!
* !value m1!value m2! .... !value mn!
* </pre>
* </p>
*
* <p>By default all column values are trimmed. To avoid trimming the values, use the "trim" inlined property:
* <pre>
* {trim=false}
* | header 1 | header 2 | .... | header n |
* | value 11 | value 12 | .... | value 1n |
* </pre>
* </p>
*
* <p>Comments is column values are supported via the "commentSeparator" inlined property:
* <pre>
* {commentSeparator=#}
* | header 1#comment | header 2 | .... | header n |
* | value 11#comment | value 12 | .... | value 1n |
* </pre>
* Comments including the separator are stripped.</p>
*
* <p>Line break is a default separator for rows in ExamplesTable, that's why they can't be added as is
* to the data. In order to put the value with line breaks to ExamplesTable escape sequences (a character preceded by a
* backslash (\) is an escape sequence) must be used.
* <table>
* <thead><tr><th>Escape Sequence</th><th>Description</th></tr></thead>
* <tbody>
* <tr><td>\n</td><td>Insert a newline in the value at this point.</td></tr>
* <tr><td>\r</td><td>Insert a carriage return in the text at this point.</td></tr>
* <tr><td>\\</td><td>Insert a backslash character in the text at this point.</td></tr>
* </tbody>
* </table>
* Inlined property <code>processEscapeSequences</code> defines whether escape sequences should be replaced in the data.
* It’s <code>false</code> by default (no property is declared explicitly). The allowed values are <code>true</code> and
* <code>false</code>, any other values are considered invalid and will lead to exception thrown at parsing.
* <pre>
* {processEscapeSequences=true, commentSeparator=#}
* |header |
* |line 1\nline 2 |# The value with a newline
* |line 1\r\nline 2|# The value with a carriage return and a newline
* |line 1\\nline 2 |# The value with an escaped escape sequence, the result will be "line 1\nline 2"
* </pre>
* </p>
*
* <p>The table allows the retrieval of row values as converted parameters. Use {@link #getRowAsParameters(int)} and
* invoke {@link Parameters#valueAs(String, Type)} specifying the header and the class type of the parameter.</p>
*
* <p>The table allows the transformation of its string representation via the "transformer" inlined property
* (multiple transformers will be applied in chain-mode):
* <pre>
* {transformer=myTransformerName}
* {transformer=myOtherTransformerName}
* |header 1|header 2| .... |header n|
* |value 11|value 12| .... |value 1n|
* ...
* |value m1|value m2| .... |value mn|
* </pre>
* The transformers need to be registered by name via the
* {@link TableTransformers#useTransformer(String, TableTransformer)}. A few transformers are already registered by
* default in {@link TableTransformers}.</p>
*
* <p>The table allow filtering on meta by row via the "metaByRow" inlined property:
* <pre>
* {metaByRow=true}
* | Meta: | header 1 | .... | header n |
* | @name=value | value 11 | .... | value 1n |
* </pre>
* </p>
*
* <p>Once created, the table row can be modified, via the {@link #withRowValues(int, Map)} method, by specifying the
* map of row values to be changed.</p>
*
* <p>A table can also be created by providing the entire data content, via the {@link #withRows(List)} method.</p>
*
* <p>The parsing code assumes that the number of columns for data rows is the same as in the header, if a row has
* less fields, the remaining are filled with empty values, if it has more, the fields are ignored.</p>
*/
public class ExamplesTable {
private static final Map<String, String> EMPTY_MAP = Collections.emptyMap();
private static final String EMPTY_VALUE = "";
public static final Pattern INLINED_PROPERTIES_PATTERN = Pattern.compile("\\{(.*?[^\\\\])\\}\\s*(.*)", DOTALL);
public static final ExamplesTable EMPTY = new ImmutableExamplesTable(EMPTY_VALUE);
private final ParameterConverters parameterConverters;
private final Row defaults;
private final TableRows tableRows;
private final Deque<TableProperties> tablePropertiesQueue = new LinkedList<>();
private Map<String, String> namedParameters = Collections.emptyMap();
private ParameterControls parameterControls;
private TableTransformerMonitor tableTransformerMonitor;
public ExamplesTable(String tableAsString) {
this(tableAsString, new TableTransformers());
}
private ExamplesTable(String tableAsString, TableTransformers tableTransformers) {
this(tableAsString, new ParameterConverters(new LoadFromClasspath(), tableTransformers), tableTransformers);
}
private ExamplesTable(String tableAsString, ParameterConverters parameterConverters,
TableTransformers tableTransformers) {
this(tableAsString, parameterConverters, new TableParsers(new LocalizedKeywords(), parameterConverters),
tableTransformers);
}
private ExamplesTable(String tableAsString, ParameterConverters parameterConverters, TableParsers tableParsers,
TableTransformers tableTransformers) {
this(tableParsers.parseProperties(tableAsString), parameterConverters, new ParameterControls(), tableParsers,
tableTransformers, new NullTableTransformerMonitor());
}
ExamplesTable(TablePropertiesQueue tablePropertiesQueue, ParameterConverters parameterConverters,
ParameterControls parameterControls, TableParsers tableParsers, TableTransformers tableTransformers,
TableTransformerMonitor tableTransformerMonitor) {
this.parameterConverters = parameterConverters;
this.parameterControls = parameterControls;
this.defaults = new ConvertedParameters(EMPTY_MAP, parameterConverters);
this.tablePropertiesQueue.addAll(tablePropertiesQueue.getProperties());
this.tableTransformerMonitor = tableTransformerMonitor;
String transformedTable = applyTransformers(tableTransformers, tablePropertiesQueue.getTable(), tableParsers);
this.tableRows = tableParsers.parseRows(transformedTable, lastTableProperties());
}
private TableProperties lastTableProperties() {
return tablePropertiesQueue.getLast();
}
private ExamplesTable(ExamplesTable other, Row defaults) {
this.tableRows = new TableRows(other.tableRows.getHeaders(),
other.tableRows.getRows());
this.parameterConverters = other.parameterConverters;
this.tablePropertiesQueue.addAll(other.tablePropertiesQueue);
this.defaults = defaults;
}
private String applyTransformers(TableTransformers tableTransformers, String tableAsString,
TableParsers tableParsers) {
String transformedTable = tableAsString;
TableProperties previousProperties = null;
for (TableProperties properties : tablePropertiesQueue) {
String transformer = properties.getTransformer();
if (transformer != null) {
if (previousProperties != null) {
properties.overrideSeparatorsFrom(previousProperties);
}
tableTransformerMonitor.beforeTransformerApplying(transformer, properties, tableAsString);
transformedTable = tableTransformers.transform(transformer, transformedTable, tableParsers, properties);
tableTransformerMonitor.afterTransformerApplying(transformer, properties, transformedTable);
}
previousProperties = properties;
}
return transformedTable;
}
public ExamplesTable withDefaults(Parameters defaults) {
return new ExamplesTable(this, new ChainedRow(defaults, this.defaults));
}
public ExamplesTable withNamedParameters(Map<String, String> namedParameters) {
this.namedParameters = namedParameters;
return this;
}
public ExamplesTable withRowValues(int rowIndex, Map<String, String> values) {
for (String header : values.keySet()) {
if (!getHeaders().contains(header)) {
getHeaders().add(header);
}
}
List<String> row = getRowValues(rowIndex);
List<String> headers = getHeaders();
for (int i = 0, headersSize = headers.size(); i < headersSize; i++) {
String value = values.get(headers.get(i));
if (i >= row.size()) {
row.add(Optional.ofNullable(value).orElse(EMPTY_VALUE));
} else if (value != null) {
row.set(i, value);
}
}
return this;
}
public ExamplesTable withRows(List<Map<String, String>> rows) {
tableRows.clear();
if (!rows.isEmpty()) {
getHeaders().addAll(rows.get(0).keySet());
rows.stream().map(Map::values).map(ArrayList::new).forEach(tableRows.rows::add);
}
return this;
}
public Properties getProperties() {
return lastTableProperties().getProperties();
}
public String getPropertiesAsString() {
return lastTableProperties().getPropertiesAsString();
}
private String replaceNamedParameters(String text) {
return parameterControls.replaceAllDelimitedNames(text, namedParameters);
}
private List<String> replaceNamedParameters(List<String> values, boolean replaceNamedParameters) {
return replaceNamedParameters ? values.stream().map(this::replaceNamedParameters).collect(toList()) : values;
}
public List<String> getHeaders() {
return tableRows.getHeaders();
}
public Map<String, String> getRow(int rowIndex) {
return getRow(rowIndex, false);
}
public Map<String, String> getRow(int rowIndex, boolean replaceNamedParameters) {
List<String> values = getRowValues(rowIndex, replaceNamedParameters);
if (!tableRows.areAllColumnsDistinct()) {
String exceptionMessage = "ExamplesTable contains non-distinct columns, all columns are: "
+ String.join(", ", getHeaders());
throw new NonDistinctColumnFound(exceptionMessage);
}
Map<String, String> result = new LinkedHashMap<>();
List<String> headers = getHeaders();
for (int i = 0, headersSize = headers.size(); i < headersSize; i++) {
result.put(headers.get(i), i < values.size() ? values.get(i) : EMPTY_VALUE);
}
return result;
}
public List<String> getRowValues(int rowIndex) {
return getRowValues(rowIndex, false);
}
public List<String> getRowValues(int rowIndex, boolean replaceNamedParameters) {
return replaceNamedParameters(tableRows.getRow(rowIndex), replaceNamedParameters);
}
public Parameters getRowAsParameters(int rowIndex) {
return getRowAsParameters(rowIndex, false);
}
public Parameters getRowAsParameters(int rowIndex, boolean replaceNamedParameters) {
Map<String, String> row = getRow(rowIndex, replaceNamedParameters);
return new ConvertedParameters(new ChainedRow(new ConvertedParameters(row, parameterConverters), defaults),
parameterConverters);
}
public int getRowCount() {
return tableRows.getRows().size();
}
public boolean metaByRow() {
return lastTableProperties().isMetaByRow();
}
public List<Map<String, String>> getRows() {
List<Map<String, String>> rows = new ArrayList<>();
for (int row = 0; row < getRowCount(); row++) {
rows.add(getRow(row));
}
return rows;
}
public List<Parameters> getRowsAsParameters() {
return getRowsAsParameters(false);
}
public List<Parameters> getRowsAsParameters(boolean replaceNamedParameters) {
List<Parameters> rows = new ArrayList<>();
for (int row = 0; row < getRowCount(); row++) {
rows.add(getRowAsParameters(row, replaceNamedParameters));
}
return rows;
}
public <T> List<T> getRowsAs(Class<T> type) {
return getRowsAs(type, Collections.emptyMap());
}
public <T> List<T> getRowsAs(Class<T> type, Map<String, String> fieldNameMapping) {
return getRowsAsParameters().stream()
.map(p -> p.mapTo(type, fieldNameMapping))
.collect(toList());
}
public List<String> getColumn(String columnName) {
return getColumn(columnName, false);
}
public List<String> getColumn(String columnName, boolean replaceNamedParameters) {
return replaceNamedParameters(tableRows.getColumn(columnName), replaceNamedParameters);
}
public String getHeaderSeparator() {
return lastTableProperties().getHeaderSeparator();
}
public String getValueSeparator() {
return lastTableProperties().getValueSeparator();
}
public String asString() {
if (tableRows.getRows().isEmpty()) {
return EMPTY_VALUE;
}
StringBuilder sb = new StringBuilder();
for (TableProperties properties : tablePropertiesQueue) {
String propertiesAsString = properties.getPropertiesAsString();
if (!propertiesAsString.isEmpty()) {
sb.append("{").append(propertiesAsString).append("}").append(lastTableProperties().getRowSeparator());
}
}
sb.append(ExamplesTableStringBuilder.buildExamplesTableString(lastTableProperties(), getHeaders(),
tableRows.getRows()));
return sb.toString();
}
public boolean isEmpty() {
return getHeaders().isEmpty();
}
public void outputTo(PrintStream output) {
output.print(asString());
}
public static ExamplesTable empty() {
return new ExamplesTable("");
}
@Override
public String toString() {
return ToStringBuilder.reflectionToString(this, ToStringStyle.SHORT_PREFIX_STYLE);
}
public static final class TableProperties {
public enum Decorator {
LOWERCASE, UPPERCASE, VERBATIM, TRIM
}
private static final String COMMA = ",";
private static final String COMMA_REGEX = "\\,";
private static final String EQUAL = "=";
private static final String PIPE_REGEX = "\\|";
private static final String DECORATORS_REGEX = Stream.of(Decorator.values())
.map(Decorator::name)
.collect(Collectors.joining("|", "(", ")"));
private static final Pattern DECORATED_PROPERTY_PATTERN = Pattern.compile(
"\\s*\\{([^=,\\s]+(\\|" + DECORATORS_REGEX + ")*)}\\s*", Pattern.CASE_INSENSITIVE);
private static final String HEADER_SEPARATOR_KEY = "headerSeparator";
private static final String VALUE_SEPARATOR_KEY = "valueSeparator";
private static final String IGNORABLE_SEPARATOR_KEY = "ignorableSeparator";
private static final String COMMENT_SEPARATOR_KEY = "commentSeparator";
private static final String NULL_PLACEHOLDER_KEY = "nullPlaceholder";
private static final String PROCESS_ESCAPE_SEQUENCES_KEY = "processEscapeSequences";
private static final String ROW_SEPARATOR = "\n";
private final Properties properties = new Properties();
private final ParameterConverters parameterConverters;
private final String propertiesAsString;
public TableProperties(String propertiesAsString, Keywords keywords, ParameterConverters parameterConverters) {
this.propertiesAsString = propertiesAsString;
this.parameterConverters = parameterConverters;
properties.setProperty(HEADER_SEPARATOR_KEY, keywords.examplesTableHeaderSeparator());
properties.setProperty(VALUE_SEPARATOR_KEY, keywords.examplesTableValueSeparator());
properties.setProperty(IGNORABLE_SEPARATOR_KEY, keywords.examplesTableIgnorableSeparator());
properties.putAll(parseProperties(propertiesAsString));
}
private Map<String, String> parseProperties(String propertiesAsString) {
Map<String, String> result = new LinkedHashMap<>();
if (!StringUtils.isEmpty(propertiesAsString)) {
for (String propertyAsString : propertiesAsString.split("(?<!\\\\),")) {
String[] property = StringUtils.split(propertyAsString, EQUAL, 2);
String propertyName = property[0];
String propertyValue = property[1];
Matcher decoratedPropertyMatcher = DECORATED_PROPERTY_PATTERN.matcher(propertyName);
if (decoratedPropertyMatcher.matches()) {
String[] propertyWithDecorators = decoratedPropertyMatcher.group(1).split(PIPE_REGEX);
propertyName = propertyWithDecorators[0];
for (int i = 1; i < propertyWithDecorators.length; i++) {
String decorator = propertyWithDecorators[i].toUpperCase();
propertyValue = decoratePropertyValue(propertyValue, Decorator.valueOf(decorator));
}
} else {
propertyValue = propertyValue.trim();
}
result.put(propertyName.trim(), StringUtils.replace(propertyValue, COMMA_REGEX, COMMA));
}
}
return result;
}
private String decoratePropertyValue(String value, Decorator decorator) {
switch (decorator) {
case VERBATIM:
return value;
case LOWERCASE:
return value.toLowerCase();
case UPPERCASE:
return value.toUpperCase();
case TRIM:
default:
return value.trim();
}
}
@SuppressWarnings("unchecked")
public <T> T getMandatoryNonBlankProperty(String propertyName, Type type) {
String propertyValue = properties.getProperty(propertyName);
isTrue(propertyValue != null, "'%s' is not set in ExamplesTable properties", propertyName);
notBlank(propertyValue, "ExamplesTable property '%s' is blank", propertyName);
return (T) parameterConverters.convert(propertyValue, type);
}
public String getRowSeparator() {
return ROW_SEPARATOR;
}
public String getHeaderSeparator() {
return properties.getProperty(HEADER_SEPARATOR_KEY);
}
public String getValueSeparator() {
return properties.getProperty(VALUE_SEPARATOR_KEY);
}
public String getIgnorableSeparator() {
return properties.getProperty(IGNORABLE_SEPARATOR_KEY);
}
public String getCommentSeparator() {
return properties.getProperty(COMMENT_SEPARATOR_KEY);
}
public Optional<String> getNullPlaceholder() {
return Optional.ofNullable(properties.getProperty(NULL_PLACEHOLDER_KEY));
}
public boolean isProcessEscapeSequences() {
String processEscapeSequences = properties.getProperty(PROCESS_ESCAPE_SEQUENCES_KEY);
if (processEscapeSequences != null) {
String processEscapeSequencesTrimmed = processEscapeSequences.trim().toLowerCase(Locale.ENGLISH);
if ("true".equals(processEscapeSequencesTrimmed)) {
return true;
}
if ("false".equals(processEscapeSequencesTrimmed)) {
return false;
}
throw new IllegalArgumentException(
"ExamplesTable property 'processEscapeSequences' contains invalid value: '"
+ processEscapeSequences + "', but allowed values are 'true' and 'false'");
}
return false;
}
void overrideSeparatorsFrom(TableProperties properties) {
Stream.of(HEADER_SEPARATOR_KEY, VALUE_SEPARATOR_KEY, IGNORABLE_SEPARATOR_KEY, COMMENT_SEPARATOR_KEY)
.forEach(key -> {
String value = properties.properties.getProperty(key);
if (value != null) {
this.properties.setProperty(key, value);
}
});
}
public boolean isTrim() {
return parseBoolean(properties.getProperty("trim", "true"));
}
public boolean isMetaByRow() {
return parseBoolean(properties.getProperty("metaByRow", "false"));
}
public String getTransformer() {
return properties.getProperty("transformer");
}
public Properties getProperties() {
return properties;
}
public String getPropertiesAsString() {
return propertiesAsString;
}
}
static final class TablePropertiesQueue {
private final String table;
private final Deque<TableProperties> properties;
TablePropertiesQueue(String table, Deque<TableProperties> properties) {
this.table = table;
this.properties = properties;
}
String getTable() {
return table;
}
Deque<TableProperties> getProperties() {
return properties;
}
}
public static final class TableRows {
private final List<String> headers;
private final List<List<String>> rows;
private final boolean allColumnsDistinct;
public TableRows(List<String> headers, List<List<String>> rows) {
this.headers = headers;
this.rows = rows;
this.allColumnsDistinct = headers.stream().distinct().count() == headers.size();
}
public List<String> getHeaders() {
return headers;
}
public List<List<String>> getRows() {
return rows;
}
private boolean areAllColumnsDistinct() {
return allColumnsDistinct;
}
private void clear() {
headers.clear();
rows.clear();
}
private List<String> getRow(int rowIndex) {
if (rowIndex > rows.size() - 1) {
throw new RowNotFound(rowIndex);
}
return rows.get(rowIndex);
}
private List<String> getColumn(int columnIndex) {
return rows.stream().map(row -> row.get(columnIndex)).collect(toList());
}
private List<String> getColumn(String columnName) {
int[] headerIndexes = IntStream.range(0, headers.size()).filter(i -> headers.get(i).equals(columnName))
.toArray();
long headerCount = headerIndexes.length;
if (headerCount == 0) {
throw new ColumnNotFound(columnName);
} else if (headerCount > 1) {
throw new NonDistinctColumnFound(columnName, headerCount);
}
return getColumn(headerIndexes[0]);
}
}
public static class RowNotFound extends RuntimeException {
static final long serialVersionUID = 6577709350720827070L;
public RowNotFound(int rowIndex) {
super(Integer.toString(rowIndex));
}
}
public static class ColumnNotFound extends RuntimeException {
static final long serialVersionUID = -6008855238823273059L;
public ColumnNotFound(String columnName) {
super(String.format("The '%s' column does not exist", columnName));
}
}
public static class NonDistinctColumnFound extends RuntimeException {
static final long serialVersionUID = -6898791308443992005L;
public NonDistinctColumnFound(String columnName, long totalColumnCount) {
super(String.format("There are %d columns with the name '%s'", totalColumnCount, columnName));
}
public NonDistinctColumnFound(String message) {
super(message);
}
}
}