blob: b64bf6af811665206fe3fed3a3872c87c4af31ff [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.apache.sis.feature;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import java.util.Objects;
import java.io.IOException;
import java.io.Serializable;
import org.opengis.parameter.ParameterDescriptorGroup;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.util.GenericName;
import org.apache.sis.internal.util.CollectionsExt;
import org.apache.sis.internal.converter.SurjectiveConverter;
import org.apache.sis.internal.feature.AttributeConvention;
import org.apache.sis.internal.feature.FeatureUtilities;
import org.apache.sis.internal.feature.Resources;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.ObjectConverter;
import org.apache.sis.util.ObjectConverters;
import org.apache.sis.util.UnconvertibleObjectException;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.Classes;
/**
* An operation concatenating the string representations of the values of multiple properties.
* This operation can be used for creating a <cite>compound key</cite> as a {@link String}
* that consists of two or more attribute values that uniquely identify a feature instance.
*
* <p>This operation supports both reading and writing. When setting a value on the attribute
* created by this operation, the value will be split and forwarded to each single attribute.</p>
*
* @author Johann Sorel (Geomatys)
* @author Martin Desruisseaux (Geomatys)
* @version 1.0
*
* @see <a href="https://en.wikipedia.org/wiki/Compound_key">Compound key on Wikipedia</a>
*
* @since 0.7
* @module
*/
final class StringJoinOperation extends AbstractOperation {
/**
* For cross-version compatibility.
*/
private static final long serialVersionUID = 2303047827010821381L;
/**
* The character used for escaping occurrences of the delimiter inside a value.
*/
static final char ESCAPE = '\\';
/**
* The parameter descriptor for the "String join" operation, which does not take any parameter.
*/
private static final ParameterDescriptorGroup EMPTY_PARAMS = FeatureUtilities.parameters("StringJoin");
/**
* A pseudo-converter returning the identifier of a feature. This pseudo-converter is used in place
* of "real" converters in the {@link StringJoinOperation#converters} array when the property is an
* association to a feature instead than an attribute. This pseudo-converters is used as below:
*
* <ul>
* <li>{@link Result#getValue()} gets this converter by a call to {@code converters[i].inverse()}.
* This works provided that {@link #inverse()} returns {@code this} (see comment below).</li>
* <li>{@link Result#setValue(String)} needs to perform a special case for this class.</li>
* </ul>
*
* This is not a well-formed converter since its {@link #inverse()} method does not fulfill the required
* semantic of {@link ObjectConverter#inverse()}, but this is okay for {@link StringJoinOperation} needs.
* This converter should never be accessible to users however.
*/
private static final class ForFeature extends SurjectiveConverter<Object,Object> implements Serializable {
/** For cross-version compatibility. */
private static final long serialVersionUID = 2208230611402221572L;
/**
* The "real" converter which would have been stored in the {@link StringJoinOperation#converters}
* array if the property was an attribute instead than an association. For formatting the feature
* identifier, we need to use the inverse of that converter.
*/
final ObjectConverter<? super String, ?> converter;
/** Creates a new wrapper over the given converter. */
ForFeature(final ObjectConverter<? super String, ?> converter) {
this.converter = converter;
}
/**
* Returns {@code this} for allowing {@link Result#getValue()} to get this pseudo-converter.
* This is a violation of {@link ObjectConverter} contract since this pseudo-converter is not
* an identity converter. Direct uses of this pseudo-converter will need a {@code instanceof}
* check instead.
*/
@Override public ObjectConverter<Object,Object> inverse() {return this;}
@Override public Class<Object> getSourceClass() {return Object.class;}
@Override public Class<Object> getTargetClass() {return Object.class;}
@Override public Object apply(final Object f) {
return (f != null) ? format(converter.inverse(),
((AbstractFeature) f).getPropertyValue(AttributeConvention.IDENTIFIER)) : null;
}
}
/**
* The name of the properties (attributes of operations producing attributes)
* from which to get the values to concatenate.
*/
private final String[] attributeNames;
/**
* Converters for parsing strings as attribute values. Those converters will be used by
* {@link Result#setValue(String)} while {@link Result#getValue()} will use the inverse
* of those converters.
*
* <p>Note: we store converters from string to value instead than the converse because
* the inverse conversion is often a simple call to {@link Object#toString()}, so there
* is a risk that some of the later converters do not bother to remember their inverse.</p>
*/
private final ObjectConverter<? super String, ?>[] converters;
/**
* The property names as an unmodifiable set, created when first needed.
*/
private transient Set<String> dependencies;
/**
* The type of the result returned by the string concatenation operation.
*/
private final DefaultAttributeType<String> resultType;
/**
* The characters to use at the beginning of the concatenated string, or an empty string if none.
*/
final String prefix;
/**
* The characters to use at the end of the concatenated string, or an empty string if none.
*/
final String suffix;
/**
* The characters to use a delimiter between each single attribute value.
*/
final String delimiter;
/**
* Creates a new operation for string concatenations using the given prefix, suffix and delimeter.
* It is caller's responsibility to ensure that {@code delimiter} and {@code singleAttributes} are not null.
* This private constructor does not verify that condition on the assumption that the public API did.
*
* @see FeatureOperations#compound(Map, String, String, String, AbstractIdentifiedType...)
*/
@SuppressWarnings({"rawtypes", "unchecked"}) // Generic array creation.
StringJoinOperation(final Map<String,?> identification, final String delimiter,
final String prefix, final String suffix, final AbstractIdentifiedType[] singleAttributes)
throws UnconvertibleObjectException
{
super(identification);
attributeNames = new String[singleAttributes.length];
converters = new ObjectConverter[singleAttributes.length];
for (int i=0; i < singleAttributes.length; i++) {
/*
* Verify the following conditions:
* - property types are non-null.
* - properties are either attributes, or operations producing attributes,
* or association to features having an "sis:identifier" property.
* - attributes contain at most one value (no collections).
*
* We test FeatureAssociationRole, Operation and AttributeType in that order
* because the "sis:identifier" property of FeatureType may be an Operation,
* which may in turn produce an AttributeType. We do not accept more complex
* combinations (e.g. operation producing an association).
*/
AbstractIdentifiedType propertyType = singleAttributes[i];
ArgumentChecks.ensureNonNullElement("singleAttributes", i, propertyType);
final GenericName name = propertyType.getName();
int maximumOccurs = 0; // May be a bitwise combination; need only to know if > 1.
IllegalArgumentException cause = null; // In case of failure to find "sis:identifier" property.
final boolean isAssociation = (propertyType instanceof DefaultAssociationRole);
if (isAssociation) {
final DefaultAssociationRole role = (DefaultAssociationRole) propertyType;
final DefaultFeatureType ft = role.getValueType();
maximumOccurs = role.getMaximumOccurs();
try {
propertyType = ft.getProperty(AttributeConvention.IDENTIFIER);
} catch (IllegalArgumentException e) {
cause = e;
}
}
if (propertyType instanceof AbstractOperation) {
propertyType = ((AbstractOperation) propertyType).getResult();
}
if (propertyType instanceof DefaultAttributeType) {
maximumOccurs |= ((DefaultAttributeType<?>) propertyType).getMaximumOccurs();
} else {
final Class<?>[] inf = Classes.getLeafInterfaces(Classes.getClass(propertyType), AbstractIdentifiedType.class);
throw new IllegalArgumentException(Resources.forProperties(identification)
.getString(Resources.Keys.IllegalPropertyType_2, name, (inf.length != 0) ? inf[0] : null), cause);
}
if (maximumOccurs > 1) {
throw new IllegalArgumentException(Resources.forProperties(identification)
.getString(Resources.Keys.NotASingleton_1, name));
}
/*
* StringJoinOperation does not need to keep the AttributeType references.
* We need only their names and how to convert from String to their values.
*/
attributeNames[i] = name.toString();
ObjectConverter<? super String, ?> converter = ObjectConverters.find(
String.class, ((DefaultAttributeType<?>) propertyType).getValueClass());
if (isAssociation) {
converter = new ForFeature(converter);
}
converters[i] = converter;
}
resultType = FeatureOperations.POOL.unique(new DefaultAttributeType<>(
resultIdentification(identification), String.class, 1, 1, null));
this.delimiter = delimiter;
this.prefix = (prefix == null) ? "" : prefix;
this.suffix = (suffix == null) ? "" : suffix;
}
/**
* Returns an empty group of parameters since this operation does not require any parameter.
*
* @return empty parameter group.
*/
@Override
public ParameterDescriptorGroup getParameters() {
return EMPTY_PARAMS;
}
/**
* Returns the type of results computed by this operation, which is {@code AttributeType<String>}.
* The attribute type name depends on the value of {@code "result.*"} properties (if any)
* given at construction time.
*
* @return an {@code AttributeType<String>}.
*/
@Override
public AbstractIdentifiedType getResult() {
return resultType;
}
/**
* Returns the names of feature properties that this operation needs for performing its task.
*/
@Override
@SuppressWarnings("ReturnOfCollectionOrArrayField")
public synchronized Set<String> getDependencies() {
if (dependencies == null) {
dependencies = CollectionsExt.immutableSet(true, attributeNames);
}
return dependencies;
}
/**
* Formats the given value using the given converter. This method is a workaround for the presence
* of the first {@code ?} in {@code ObjectConverter<?,?>}: defining a separated method allows us
* to replace that {@code <?>} by {@code <S>}, thus allowing the compiler to verify consistency.
*
* @param converter the converter to use for formatting the given value.
* @param value the value to format, or {@code null}.
*/
static <S> Object format(final ObjectConverter<S,?> converter, final Object value) {
return converter.apply(converter.getSourceClass().cast(value));
}
/**
* Returns the concatenation of property values of the given feature.
*
* @param feature the feature on which to execute the operation.
* @param parameters ignored (can be {@code null}).
* @return the concatenation of feature property values.
*/
@Override
public Property apply(AbstractFeature feature, ParameterValueGroup parameters) {
ArgumentChecks.ensureNonNull("feature", feature);
return new Result(feature);
}
/**
* The attributes that contains the result of concatenating the string representation of other attributes.
* Value is calculated each time it is accessed.
*/
private final class Result extends AbstractAttribute<String> {
/**
* For cross-version compatibility.
*/
private static final long serialVersionUID = -8435975199763452547L;
/**
* The feature specified to the {@link StringJoinOperation#apply(AbstractFeature, ParameterValueGroup)} method.
*/
private final AbstractFeature feature;
/**
* Creates a new attribute for the given feature.
*/
Result(final AbstractFeature feature) {
super(resultType);
this.feature = feature;
}
/**
* Creates a string which is the concatenation of attribute values of all properties
* specified to the {@link StringJoinOperation} constructor.
*
* @return the concatenated string.
* @throws UnconvertibleObjectException if one of the attribute values is not of the expected type.
*/
@Override
public String getValue() throws UnconvertibleObjectException {
final StringBuilder sb = new StringBuilder();
String sep = prefix;
String name = null;
Object value = null;
try {
for (int i=0; i < attributeNames.length; i++) {
name = attributeNames[i];
value = feature.getPropertyValue(name); // Used in 'catch' block in case of exception.
value = format(converters[i].inverse(), value);
sb.append(sep);
sep = delimiter;
if (value != null) {
/*
* First insert the value, then substitute in-place all occurrences of "\" by "\\"
* then all occurence of the delimiter by "\" followed by the delimiter.
*/
final int startAt = sb.length();
int j = sb.append(value).length();
while (--j >= startAt) {
if (sb.charAt(j) == ESCAPE) {
sb.insert(j, ESCAPE);
}
}
j = startAt;
while ((j = sb.indexOf(sep, j)) >= 0) {
sb.insert(j, ESCAPE);
j += sep.length() + 1;
}
}
}
} catch (ClassCastException e) {
if (value == null) {
throw e;
}
throw new UnconvertibleObjectException(Errors.format(
Errors.Keys.IncompatiblePropertyValue_1, name), e);
}
return sb.append(suffix).toString();
}
/**
* Given a concatenated string as produced by {@link #getValue()}, separates the components around
* the separator and forward the values to the original attributes. If one of the values can not be
* parsed, then this method does not store any property value ("all or nothing" behavior).
*
* @param value the concatenated string.
* @throws IllegalArgumentException if one of the attribute values can not be parsed to the expected type.
*/
@Override
public void setValue(final String value) throws IllegalArgumentException {
final int endAt = value.length() - suffix.length();
final boolean prefixMatches = value.startsWith(prefix);
if (!prefixMatches || !value.endsWith(suffix)) {
throw new IllegalArgumentException(Errors.format(Errors.Keys.UnexpectedCharactersAtBound_4,
getName(),
prefixMatches ? 1 : 0, // For "{1,choice,0#begin|1#end}" in message format.
prefixMatches ? suffix : prefix,
prefixMatches ? value.substring(Math.max(0, endAt)) : CharSequences.token(value, 0)));
}
/*
* We do not use the regex split for avoiding possible reserved regex characters,
* and also for processing directly escaped delimiters. We convert the values as we
* read them (no need to store the substrings) but do not store them in the properties
* before we succeeded to parse all values, so we have a "all or nothing" behavior.
*/
final Object[] values = new Object[attributeNames.length];
int lower = prefix.length();
int upper = lower;
int count = 0;
boolean done = false;
do {
upper = value.indexOf(delimiter, upper);
if (upper >= 0 && upper < endAt) {
/*
* If an odd number of escape characters exist before the delimiter, remove the last
* escape character and continue the search for the next delimiter.
*/
int escape = upper;
while (escape != 0 && value.charAt(escape - 1) == ESCAPE) {
escape--;
}
if (((upper - escape) & 1) != 0) {
upper += delimiter.length() + 1;
continue;
}
} else {
upper = endAt;
done = true;
}
/*
* Get the value and remove all escape characters. Each escape character is either followed by another
* escape character (that we need to keep) or the delimiter. The algorithm used here is inefficient
* (we recreate a buffer for each character to remove), but we assume that it should be rarely needed.
*/
String element = value.substring(lower, upper);
for (int i=0; (i = element.indexOf(ESCAPE, i)) >= 0;) {
element = new StringBuilder(element.length() - 1)
.append(element, 0, i).append(element, i+1, element.length()).toString();
if (i < element.length()) {
if (element.charAt(i) == ESCAPE) {
i++;
} else {
assert element.regionMatches(i, delimiter, 0, delimiter.length()) : element;
i += delimiter.length();
}
}
}
/*
* Empty strings are considered as null values for consistency with StringJoinOperation.format(…).
* If we have more values than expected, continue the parsing but without storing the values.
* The intent is to get the correct count of values for error reporting.
*/
if (!element.isEmpty() && count < values.length) {
ObjectConverter<? super String, ?> converter = converters[count];
if (converter instanceof ForFeature) {
converter = ((ForFeature) converter).converter;
}
try {
values[count] = converter.apply(element);
} catch (UnconvertibleObjectException e) {
throw new IllegalArgumentException(Errors.format(
Errors.Keys.CanNotAssign_2, attributeNames[count], element), e);
}
}
count++;
upper += delimiter.length();
lower = upper;
} while (!done);
/*
* Store the values in the properties only after we successfully converted all of them,
* in order to have a "all or nothing" behavior (assuming that calls to Feature methods
* below do not fail).
*/
if (values.length != count) {
throw new IllegalArgumentException(Resources.format(
Resources.Keys.UnexpectedNumberOfComponents_4, getName(), value, values.length, count));
}
for (int i=0; i < values.length; i++) {
AbstractFeature f = feature;
String name = attributeNames[i];
if (converters[i] instanceof ForFeature) {
f = (AbstractFeature) f.getPropertyValue(name);
name = AttributeConvention.IDENTIFIER;
}
f.setPropertyValue(name, values[i]);
}
}
}
/**
* Computes a hash-code value for this operation.
*/
@Override
public int hashCode() {
return super.hashCode() + Arrays.hashCode(attributeNames) + 37 * Objects.hash(delimiter, prefix, suffix);
}
/**
* Compares this operation with the given object for equality.
*/
@Override
public boolean equals(final Object obj) {
if (super.equals(obj)) {
// 'this.result' is compared (indirectly) by the super class.
final StringJoinOperation that = (StringJoinOperation) obj;
return Arrays.equals(this.attributeNames, that.attributeNames) &&
Arrays.equals(this.converters, that.converters) &&
Objects.equals(this.delimiter, that.delimiter) &&
Objects.equals(this.prefix, that.prefix) &&
Objects.equals(this.suffix, that.suffix);
}
return false;
}
/**
* Appends a string representation of the "formula" used for computing the result.
*
* @param buffer where to format the "formula".
*/
@Override
void formatResultFormula(final Appendable buffer) throws IOException {
final String escape = ESCAPE + delimiter;
if (prefix != null) buffer.append(prefix);
for (int i=0; i<attributeNames.length; i++) {
if (i != 0) buffer.append(delimiter);
buffer.append(attributeNames[i].replace(delimiter, escape));
}
if (suffix != null) buffer.append(suffix);
}
}