package org.jbehave.core.model; import java.io.PrintStream; import java.lang.reflect.Field; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Properties; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; import org.jbehave.core.annotations.Parameter; import org.jbehave.core.model.TableTransformers.TableTransformer; import org.jbehave.core.steps.ChainedRow; import org.jbehave.core.steps.ConvertedParameters; import org.jbehave.core.steps.ParameterConverters; import org.jbehave.core.steps.Parameters; import org.jbehave.core.steps.Row; import static java.util.regex.Pattern.DOTALL; /** * <p> * Represents a tabular structure that holds rows of example data for parameters * named via the column headers: * <p/> * * <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 "|": * </p> * * <pre> * !!header 1!!header 2!! .... !!header n!! * !value 11!value 12! .... !value 1n! * ... * !value m1!value m2| .... !value mn! * </pre> * <p> * Rows starting with an ignorable separator are allowed and ignored: * </p> * * <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> * 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> * 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> * The table allows the retrieval of row values as converted parameters. Use * {@link #getRowAsParameters(int)} and invoke * {@link Parameters#valueAs(String, Class)} 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: * * <pre> * {transformer=myTransformerName} * |header 1|header 2| .... |header n| * |value 11|value 12| .... |value 1n| * ... * |value m1|value m2| .... |value mn| * </pre> * * The transformer needs 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<Map<String,String>>)} method. * * </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 ExamplesTable(""); private static final String ROW_SEPARATOR_PATTERN = "\r?\n"; private static final String HEADER_SEPARATOR = "|"; private static final String VALUE_SEPARATOR = "|"; private static final String IGNORABLE_SEPARATOR = "|--"; private final String tableAsString; private final ParameterConverters parameterConverters; private final TableTransformers tableTransformers; private final Row defaults; private final List<String> headers = new ArrayList<String>(); private final List<Map<String, String>> data = new ArrayList<Map<String, String>>(); private ExamplesTableProperties properties; private String propertiesAsString = ""; private Map<String, String> namedParameters = new HashMap<String, String>(); public ExamplesTable(String tableAsString) { this(tableAsString, HEADER_SEPARATOR, VALUE_SEPARATOR, new TableTransformers()); } public ExamplesTable(String tableAsString, String headerSeparator, String valueSeparator, TableTransformers tableTransformers) { this(tableAsString, headerSeparator, valueSeparator, IGNORABLE_SEPARATOR, new ParameterConverters(tableTransformers), tableTransformers); } public ExamplesTable(String tableAsString, String headerSeparator, String valueSeparator, String ignorableSeparator, ParameterConverters parameterConverters, TableTransformers tableTransformers) { this.tableAsString = tableAsString; this.parameterConverters = parameterConverters; this.tableTransformers = tableTransformers; this.defaults = new ConvertedParameters(EMPTY_MAP, parameterConverters); parse(headerSeparator, valueSeparator, ignorableSeparator); } private void parse(String headerSeparator, String valueSeparator, String ignorableSeparator) { String tableWithoutProperties = stripProperties(tableAsString.trim()); properties = new ExamplesTableProperties(propertiesAsString, headerSeparator, valueSeparator, ignorableSeparator); parseTable(tableWithoutProperties); } private ExamplesTable(ExamplesTable other, Row defaults) { this.data.addAll(other.data); this.tableAsString = other.tableAsString; this.parameterConverters = other.parameterConverters; this.tableTransformers = other.tableTransformers; this.headers.addAll(other.headers); this.properties = other.properties; this.defaults = defaults; } private String stripProperties(String tableAsString) { Matcher matcher = INLINED_PROPERTIES_PATTERN.matcher(tableAsString); if (matcher.matches()) { propertiesAsString = matcher.group(1); return matcher.group(2); } return tableAsString; } private void parseTable(String tableAsString) { headers.clear(); data.clear(); String transformer = properties.getTransformer(); if (transformer != null) { tableAsString = tableTransformers.transform(transformer, tableAsString, properties); } parseByRows(headers, data, tableAsString); } private void parseByRows(List<String> headers, List<Map<String, String>> data, String tableAsString) { String[] rows = tableAsString.split(ROW_SEPARATOR_PATTERN); for (int row = 0; row < rows.length; row++) { String rowAsString = rows[row]; if (rowAsString.startsWith(properties.getIgnorableSeparator()) || rowAsString.length() == 0) { // skip ignorable or empty lines continue; } else if (headers.isEmpty()) { headers.addAll(TableUtils.parseRow(rowAsString, true, properties)); } else { List<String> columns = TableUtils.parseRow(rowAsString, false, properties); Map<String, String> map = new LinkedHashMap<String, String>(); for (int column = 0; column < columns.size(); column++) { if (column < headers.size()) { map.put(headers.get(column), columns.get(column)); } } data.add(map); } } } 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 row, Map<String, String> values) { getRow(row).putAll(values); for (String header : values.keySet()) { if (!headers.contains(header)) { headers.add(header); } } return this; } public ExamplesTable withRows(List<Map<String, String>> values) { this.data.clear(); this.data.addAll(values); this.headers.clear(); this.headers.addAll(values.get(0).keySet()); return this; } public Properties getProperties() { return properties.getProperties(); } public List<String> getHeaders() { return headers; } public Map<String, String> getRow(int row) { if (row > data.size() - 1) { throw new RowNotFound(row); } Map<String, String> values = data.get(row); if (headers.size() != values.keySet().size()) { for (String header : headers) { if (!values.containsKey(header)) { values.put(header, EMPTY_VALUE); } } } return values; } public Parameters getRowAsParameters(int row) { return getRowAsParameters(row, false); } public Parameters getRowAsParameters(int row, boolean replaceNamedParameters) { Map<String, String> rowValues = getRow(row); return createParameters((replaceNamedParameters ? replaceNamedParameters(rowValues) : rowValues)); } private Map<String, String> replaceNamedParameters(Map<String, String> row) { Map<String, String> replaced = new LinkedHashMap<String, String>(); for (Entry<String, String> rowEntry : row.entrySet()) { String replacedValue = rowEntry.getValue(); for (String namedKey : namedParameters.keySet()) { String namedValue = namedParameters.get(namedKey); replacedValue = replacedValue.replaceAll(namedKey, Matcher.quoteReplacement(namedValue)); } replaced.put(rowEntry.getKey(), replacedValue); } return replaced; } public int getRowCount() { return data.size(); } public boolean metaByRow(){ return properties.isMetaByRow(); } public List<Map<String, String>> getRows() { List<Map<String, String>> rows = new ArrayList<Map<String, String>>(); 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<Parameters>(); 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, new HashMap<String, String>()); } public <T> List<T> getRowsAs(Class<T> type, Map<String, String> fieldNameMapping) { List<T> rows = new ArrayList<T>(); for (Parameters parameters : getRowsAsParameters()) { rows.add(mapToType(parameters, type, fieldNameMapping)); } return rows; } private <T> T mapToType(Parameters parameters, Class<T> type, Map<String, String> fieldNameMapping) { try { T instance = type.newInstance(); Map<String, String> values = parameters.values(); for (String name : values.keySet()) { Field field = findField(type, name, fieldNameMapping); Type fieldType = field.getGenericType(); Object value = parameters.valueAs(name, fieldType); field.setAccessible(true); field.set(instance, value); } return instance; } catch (Exception e) { throw new ParametersNotMappableToType(parameters, type, e); } } private <T> Field findField(Class<T> type, String name, Map<String, String> fieldNameMapping) throws NoSuchFieldException { // Get field name from mapping, if specified String fieldName = fieldNameMapping.get(name); if (fieldName == null) { fieldName = name; } // First look for fields annotated by @Parameter specifying the name for (Field field : type.getDeclaredFields()) { if (field.isAnnotationPresent(Parameter.class)) { Parameter parameter = field.getAnnotation(Parameter.class); if (fieldName.equals(parameter.name())) { return field; } } } // Default to field matching given name return type.getDeclaredField(fieldName); } private Parameters createParameters(Map<String, String> values) { return new ConvertedParameters(new ChainedRow(new ConvertedParameters(values, parameterConverters), defaults), parameterConverters); } public String getHeaderSeparator() { return properties.getHeaderSeparator(); } public String getValueSeparator() { return properties.getValueSeparator(); } public String asString() { if (data.isEmpty()) { return EMPTY_VALUE; } return format(); } public void outputTo(PrintStream output) { output.print(asString()); } private String format() { StringBuilder sb = new StringBuilder(); if (!propertiesAsString.isEmpty()){ sb.append("{").append(propertiesAsString).append("}").append(properties.getRowSeparator()); } for (String header : headers) { sb.append(getHeaderSeparator()).append(header); } sb.append(getHeaderSeparator()).append(properties.getRowSeparator()); for (Map<String, String> row : getRows()) { for (String header : headers) { sb.append(getValueSeparator()).append(row.get(header)); } sb.append(getValueSeparator()).append(properties.getRowSeparator()); } return sb.toString(); } @Override public String toString() { return ToStringBuilder.reflectionToString(this, ToStringStyle.SHORT_PREFIX_STYLE); } @SuppressWarnings("serial") public static class RowNotFound extends RuntimeException { public RowNotFound(int row) { super(Integer.toString(row)); } } @SuppressWarnings("serial") public static class ParametersNotMappableToType extends RuntimeException { public ParametersNotMappableToType(Parameters parameters, Class<?> type, Exception e) { super(parameters.values() + " not mappable to type " + type, e); } } }