blob: fb831f5b10c5bd25940edf62cd418d1f6b928461 [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.solr.schema;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.util.ArrayList;
import java.util.Currency;
import java.util.List;
import java.util.Map;
import org.apache.lucene.analysis.util.ResourceLoader;
import org.apache.lucene.analysis.util.ResourceLoaderAware;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.index.IndexableField;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.queries.function.FunctionValues;
import org.apache.lucene.queries.function.ValueSource;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.ConstantScoreQuery;
import org.apache.lucene.search.DocValuesFieldExistsQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.SortField;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.SolrException.ErrorCode;
import org.apache.solr.response.TextResponseWriter;
import org.apache.solr.search.QParser;
import org.apache.solr.search.function.ValueSourceRangeFilter;
import org.apache.solr.uninverting.UninvertingReader.Type;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Field type for support of monetary values.
* <p>
* See <a href="http://wiki.apache.org/solr/CurrencyField">http://wiki.apache.org/solr/CurrencyField</a>
*/
public class CurrencyFieldType extends FieldType implements SchemaAware, ResourceLoaderAware {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
protected static final String PARAM_DEFAULT_CURRENCY = "defaultCurrency";
protected static final String DEFAULT_DEFAULT_CURRENCY = "USD";
protected static final String PARAM_RATE_PROVIDER_CLASS = "providerClass";
protected static final String DEFAULT_RATE_PROVIDER_CLASS = "solr.FileExchangeRateProvider";
protected static final String PARAM_FIELD_SUFFIX_AMOUNT_RAW = "amountLongSuffix";
protected static final String PARAM_FIELD_SUFFIX_CURRENCY = "codeStrSuffix";
protected IndexSchema schema;
protected FieldType fieldTypeCurrency;
protected FieldType fieldTypeAmountRaw;
protected String fieldSuffixAmountRaw;
protected String fieldSuffixCurrency;
private String exchangeRateProviderClass;
private String defaultCurrency;
private ExchangeRateProvider provider;
/**
* A wrapper around <code>Currency.getInstance</code> that returns null
* instead of throwing <code>IllegalArgumentException</code>
* if the specified Currency does not exist in this JVM.
*
* @see Currency#getInstance(String)
*/
public static Currency getCurrency(final String code) {
try {
return Currency.getInstance(code);
} catch (IllegalArgumentException e) {
/* :NOOP: */
}
return null;
}
/** The identifier code for the default currency of this field type */
public String getDefaultCurrency() {
return defaultCurrency;
}
@Override
protected void init(IndexSchema schema, Map<String, String> args) {
super.init(schema, args);
if (this.isMultiValued()) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR,
getClass().getSimpleName() + " types can not be multiValued: " + this.typeName);
}
this.schema = schema;
this.defaultCurrency = args.get(PARAM_DEFAULT_CURRENCY);
if (this.defaultCurrency == null) {
this.defaultCurrency = DEFAULT_DEFAULT_CURRENCY;
} else {
args.remove(PARAM_DEFAULT_CURRENCY);
}
if (null == getCurrency(this.defaultCurrency)) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
"Default currency code is not supported by this JVM: " + this.defaultCurrency);
}
this.exchangeRateProviderClass = args.get(PARAM_RATE_PROVIDER_CLASS);
if (this.exchangeRateProviderClass == null) {
this.exchangeRateProviderClass = DEFAULT_RATE_PROVIDER_CLASS;
} else {
args.remove(PARAM_RATE_PROVIDER_CLASS);
}
try {
Class<? extends ExchangeRateProvider> c
= schema.getResourceLoader().findClass(exchangeRateProviderClass, ExchangeRateProvider.class);
provider = c.newInstance();
provider.init(args);
} catch (Exception e) {
throw new SolrException(ErrorCode.SERVER_ERROR,
"Error instantiating exchange rate provider " + exchangeRateProviderClass + ": " + e.getMessage(), e);
}
if (fieldTypeAmountRaw == null) { // Don't initialize if subclass already has done so
fieldSuffixAmountRaw = args.get(PARAM_FIELD_SUFFIX_AMOUNT_RAW);
if (fieldSuffixAmountRaw == null) {
throw new SolrException(ErrorCode.SERVER_ERROR, "Missing required param " + PARAM_FIELD_SUFFIX_AMOUNT_RAW);
} else {
args.remove(PARAM_FIELD_SUFFIX_AMOUNT_RAW);
}
}
if (fieldTypeCurrency == null) { // Don't initialize if subclass already has done so
fieldSuffixCurrency = args.get(PARAM_FIELD_SUFFIX_CURRENCY);
if (fieldSuffixCurrency == null) {
throw new SolrException(ErrorCode.SERVER_ERROR, "Missing required param " + PARAM_FIELD_SUFFIX_CURRENCY);
} else {
args.remove(PARAM_FIELD_SUFFIX_CURRENCY);
}
}
}
@Override
public boolean isPolyField() {
return true;
}
@Override
public void checkSchemaField(final SchemaField field) throws SolrException {
super.checkSchemaField(field);
if (field.multiValued()) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR,
getClass().getSimpleName() + " fields can not be multiValued: " + field.getName());
}
}
@Override
public List<IndexableField> createFields(SchemaField field, Object externalVal) {
CurrencyValue value = CurrencyValue.parse(externalVal.toString(), defaultCurrency);
List<IndexableField> f = new ArrayList<>();
SchemaField amountField = getAmountField(field);
f.add(amountField.createField(String.valueOf(value.getAmount())));
SchemaField currencyField = getCurrencyField(field);
f.add(currencyField.createField(value.getCurrencyCode()));
if (field.stored()) {
String storedValue = externalVal.toString().trim();
if (storedValue.indexOf(",") < 0) {
storedValue += "," + defaultCurrency;
}
f.add(createField(field.getName(), storedValue, StoredField.TYPE));
}
return f;
}
private SchemaField getAmountField(SchemaField field) {
return schema.getField(field.getName() + POLY_FIELD_SEPARATOR + fieldSuffixAmountRaw);
}
private SchemaField getCurrencyField(SchemaField field) {
return schema.getField(field.getName() + POLY_FIELD_SEPARATOR + fieldSuffixCurrency);
}
/**
* When index schema is informed, get field types for the configured dynamic sub-fields
*
* {@inheritDoc}
*
* @param schema {@inheritDoc}
*/
@Override
public void inform(IndexSchema schema) {
this.schema = schema;
if (null == fieldTypeAmountRaw) {
assert null != fieldSuffixAmountRaw : "How did we get here?";
SchemaField field = schema.getFieldOrNull(POLY_FIELD_SEPARATOR + fieldSuffixAmountRaw);
if (field == null) {
throw new SolrException(ErrorCode.SERVER_ERROR, "Field type \"" + this.getTypeName()
+ "\": Undefined dynamic field for " + PARAM_FIELD_SUFFIX_AMOUNT_RAW + "=\"" + fieldSuffixAmountRaw + "\"");
}
fieldTypeAmountRaw = field.getType();
if (!(fieldTypeAmountRaw instanceof LongValueFieldType)) {
throw new SolrException(ErrorCode.SERVER_ERROR, "Field type \"" + this.getTypeName()
+ "\": Dynamic field for " + PARAM_FIELD_SUFFIX_AMOUNT_RAW + "=\"" + fieldSuffixAmountRaw
+ "\" must have type class extending LongValueFieldType");
}
}
if (null == fieldTypeCurrency) {
assert null != fieldSuffixCurrency : "How did we get here?";
SchemaField field = schema.getFieldOrNull(POLY_FIELD_SEPARATOR + fieldSuffixCurrency);
if (field == null) {
throw new SolrException(ErrorCode.SERVER_ERROR, "Field type \"" + this.getTypeName()
+ "\": Undefined dynamic field for " + PARAM_FIELD_SUFFIX_CURRENCY + "=\"" + fieldSuffixCurrency + "\"");
}
fieldTypeCurrency = field.getType();
if (!(fieldTypeCurrency instanceof StrField)) {
throw new SolrException(ErrorCode.SERVER_ERROR, "Field type \"" + this.getTypeName()
+ "\": Dynamic field for " + PARAM_FIELD_SUFFIX_CURRENCY + "=\"" + fieldSuffixCurrency
+ "\" must have type class of (or extending) StrField");
}
}
}
/**
* Load the currency config when resource loader initialized.
*
* @param resourceLoader The resource loader.
*/
@Override
public void inform(ResourceLoader resourceLoader) {
provider.inform(resourceLoader);
boolean reloaded = provider.reload();
if(!reloaded) {
log.warn("Failed reloading currencies");
}
}
@Override
public Query getFieldQuery(QParser parser, SchemaField field, String externalVal) {
CurrencyValue value = CurrencyValue.parse(externalVal, defaultCurrency);
CurrencyValue valueDefault;
valueDefault = value.convertTo(provider, defaultCurrency);
return getRangeQueryInternal(parser, field, valueDefault, valueDefault, true, true);
}
/**
* <p>
* Returns a ValueSource over this field in which the numeric value for
* each document represents the indexed value as converted to the default
* currency for the field, normalized to its most granular form based
* on the default fractional digits.
* </p>
* <p>
* For example: If the default Currency specified for a field is
* <code>USD</code>, then the values returned by this value source would
* represent the equivalent number of "cents" (ie: value in dollars * 100)
* after converting each document's native currency to USD -- because the
* default fractional digits for <code>USD</code> is "<code>2</code>".
* So for a document whose indexed value was currently equivalent to
* "<code>5.43,USD</code>" using the the exchange provider for this field,
* this ValueSource would return a value of "<code>543</code>"
* </p>
*
* @see #PARAM_DEFAULT_CURRENCY
* @see #DEFAULT_DEFAULT_CURRENCY
* @see Currency#getDefaultFractionDigits
* @see #getConvertedValueSource
*/
public RawCurrencyValueSource getValueSource(SchemaField field,
QParser parser) {
getAmountField(field).checkFieldCacheSource();
getCurrencyField(field).checkFieldCacheSource();
return new RawCurrencyValueSource(field, defaultCurrency, parser);
}
/**
* <p>
* Returns a ValueSource over this field in which the numeric value for
* each document represents the value from the underlying
* <code>RawCurrencyValueSource</code> as converted to the specified target
* Currency.
* </p>
* <p>
* For example: If the <code>targetCurrencyCode</code> param is set to
* <code>USD</code>, then the values returned by this value source would
* represent the equivalent number of dollars after converting each
* document's raw value to <code>USD</code>. So for a document whose
* indexed value was currently equivalent to "<code>5.43,USD</code>"
* using the the exchange provider for this field, this ValueSource would
* return a value of "<code>5.43</code>"
* </p>
*
* @param targetCurrencyCode The target currency for the resulting value source, if null the defaultCurrency for this field type will be used
* @param source the raw ValueSource to wrap
* @see #PARAM_DEFAULT_CURRENCY
* @see #DEFAULT_DEFAULT_CURRENCY
* @see #getValueSource
*/
public ValueSource getConvertedValueSource(String targetCurrencyCode,
RawCurrencyValueSource source) {
if (null == targetCurrencyCode) {
targetCurrencyCode = defaultCurrency;
}
return new ConvertedCurrencyValueSource(targetCurrencyCode,
source);
}
/**
* Override the default existenceQuery implementation to run an existence query on the underlying amountField instead.
*/
@Override
public Query getExistenceQuery(QParser parser, SchemaField field) {
// Use an existence query of the underlying amount field
SchemaField amountField = getAmountField(field);
return amountField.getType().getExistenceQuery(parser, amountField);
}
@Override
protected Query getSpecializedRangeQuery(QParser parser, SchemaField field, String part1, String part2, final boolean minInclusive, final boolean maxInclusive) {
final CurrencyValue p1 = CurrencyValue.parse(part1, defaultCurrency);
final CurrencyValue p2 = CurrencyValue.parse(part2, defaultCurrency);
if (p1 != null && p2 != null && !p1.getCurrencyCode().equals(p2.getCurrencyCode())) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
"Cannot parse range query " + part1 + " to " + part2 +
": range queries only supported when upper and lower bound have same currency.");
}
return getRangeQueryInternal(parser, field, p1, p2, minInclusive, maxInclusive);
}
private Query getRangeQueryInternal(QParser parser, SchemaField field, final CurrencyValue p1, final CurrencyValue p2, final boolean minInclusive, final boolean maxInclusive) {
String currencyCode = (p1 != null) ? p1.getCurrencyCode() :
(p2 != null) ? p2.getCurrencyCode() : defaultCurrency;
// ValueSourceRangeFilter doesn't check exists(), so we have to
final Query docsWithValues = new DocValuesFieldExistsQuery(getAmountField(field).getName());
final Query vsRangeFilter = new ValueSourceRangeFilter
(new RawCurrencyValueSource(field, currencyCode, parser),
p1 == null ? null : p1.getAmount() + "",
p2 == null ? null : p2.getAmount() + "",
minInclusive, maxInclusive);
return new ConstantScoreQuery(new BooleanQuery.Builder()
.add(docsWithValues, Occur.FILTER)
.add(vsRangeFilter, Occur.FILTER).build());
}
@Override
public SortField getSortField(SchemaField field, boolean reverse) {
// Convert all values to default currency for sorting.
return (new RawCurrencyValueSource(field, defaultCurrency, null)).getSortField(reverse);
}
@Override
public Type getUninversionType(SchemaField sf) {
return null;
}
@Override
public void write(TextResponseWriter writer, String name, IndexableField field) throws IOException {
writer.writeStr(name, field.stringValue(), true);
}
public ExchangeRateProvider getProvider() {
return provider;
}
/**
* <p>
* A value source whose values represent the "normal" values
* in the specified target currency.
* </p>
* @see RawCurrencyValueSource
*/
class ConvertedCurrencyValueSource extends ValueSource {
private final Currency targetCurrency;
private final RawCurrencyValueSource source;
private final double rate;
public ConvertedCurrencyValueSource(String targetCurrencyCode,
RawCurrencyValueSource source) {
this.source = source;
this.targetCurrency = getCurrency(targetCurrencyCode);
if (null == targetCurrency) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Currency code not supported by this JVM: " + targetCurrencyCode);
}
// the target digits & currency of our source,
// become the source digits & currency of ourselves
this.rate = provider.getExchangeRate
(source.getTargetCurrency().getCurrencyCode(),
targetCurrency.getCurrencyCode());
}
@Override
public FunctionValues getValues(@SuppressWarnings({"rawtypes"})Map context, LeafReaderContext reader)
throws IOException {
final FunctionValues amounts = source.getValues(context, reader);
// the target digits & currency of our source,
// become the source digits & currency of ourselves
final String sourceCurrencyCode = source.getTargetCurrency().getCurrencyCode();
final double divisor = Math.pow(10D, targetCurrency.getDefaultFractionDigits());
return new FunctionValues() {
@Override
public boolean exists(int doc) throws IOException {
return amounts.exists(doc);
}
@Override
public long longVal(int doc) throws IOException {
return (long) doubleVal(doc);
}
@Override
public int intVal(int doc) throws IOException {
return (int) doubleVal(doc);
}
@Override
public double doubleVal(int doc) throws IOException {
return CurrencyValue.convertAmount(rate, sourceCurrencyCode, amounts.longVal(doc), targetCurrency.getCurrencyCode()) / divisor;
}
@Override
public float floatVal(int doc) throws IOException {
return CurrencyValue.convertAmount(rate, sourceCurrencyCode, amounts.longVal(doc), targetCurrency.getCurrencyCode()) / ((float)divisor);
}
@Override
public String strVal(int doc) throws IOException {
return Double.toString(doubleVal(doc));
}
@Override
public String toString(int doc) throws IOException {
return name() + '(' + strVal(doc) + ')';
}
};
}
public String name() {
return "currency";
}
@Override
public String description() {
return name() + "(" + source.getField().getName() + "," + targetCurrency.getCurrencyCode()+")";
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ConvertedCurrencyValueSource that = (ConvertedCurrencyValueSource) o;
return !(source != null ? !source.equals(that.source) : that.source != null) &&
(rate == that.rate) &&
!(targetCurrency != null ? !targetCurrency.equals(that.targetCurrency) : that.targetCurrency != null);
}
@Override
public int hashCode() {
int result = targetCurrency != null ? targetCurrency.hashCode() : 0;
result = 31 * result + (source != null ? source.hashCode() : 0);
result = 31 * (int) Double.doubleToLongBits(rate);
return result;
}
}
/**
* <p>
* A value source whose values represent the "raw" (ie: normalized using
* the number of default fractional digits) values in the specified
* target currency).
* </p>
* <p>
* For example: if the specified target currency is "<code>USD</code>"
* then the numeric values are the number of pennies in the value
* (ie: <code>$n * 100</code>) since the number of default fractional
* digits for <code>USD</code> is "<code>2</code>")
* </p>
* @see ConvertedCurrencyValueSource
*/
class RawCurrencyValueSource extends ValueSource {
private static final long serialVersionUID = 1L;
private final Currency targetCurrency;
private ValueSource currencyValues;
private ValueSource amountValues;
private final SchemaField sf;
public RawCurrencyValueSource(SchemaField sfield, String targetCurrencyCode, QParser parser) {
this.sf = sfield;
this.targetCurrency = getCurrency(targetCurrencyCode);
if (null == targetCurrency) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Currency code not supported by this JVM: " + targetCurrencyCode);
}
SchemaField amountField = getAmountField(sf);
SchemaField currencyField = getCurrencyField(sf);
currencyValues = currencyField.getType().getValueSource(currencyField, parser);
amountValues = amountField.getType().getValueSource(amountField, parser);
}
public SchemaField getField() { return sf; }
public Currency getTargetCurrency() { return targetCurrency; }
@Override
@SuppressWarnings({"unchecked"})
public FunctionValues getValues(@SuppressWarnings({"rawtypes"})Map context, LeafReaderContext reader) throws IOException {
final FunctionValues amounts = amountValues.getValues(context, reader);
final FunctionValues currencies = currencyValues.getValues(context, reader);
return new FunctionValues() {
private static final int MAX_CURRENCIES_TO_CACHE = 256;
private final int[] fractionDigitCache = new int[MAX_CURRENCIES_TO_CACHE];
private final String[] currencyOrdToCurrencyCache = new String[MAX_CURRENCIES_TO_CACHE];
private final double[] exchangeRateCache = new double[MAX_CURRENCIES_TO_CACHE];
private int targetFractionDigits = -1;
private int targetCurrencyOrd = -1;
private boolean initializedCache;
private String getDocCurrencyCode(int doc, int currencyOrd) throws IOException {
if (currencyOrd < MAX_CURRENCIES_TO_CACHE) {
String currency = currencyOrdToCurrencyCache[currencyOrd];
if (currency == null) {
currencyOrdToCurrencyCache[currencyOrd] = currency = currencies.strVal(doc);
}
if (currency == null) {
currency = defaultCurrency;
}
if (targetCurrencyOrd == -1 &&
currency.equals(targetCurrency.getCurrencyCode() )) {
targetCurrencyOrd = currencyOrd;
}
return currency;
} else {
return currencies.strVal(doc);
}
}
/** throws a (Server Error) SolrException if the code is not valid */
private Currency getDocCurrency(int doc, int currencyOrd) throws IOException {
String code = getDocCurrencyCode(doc, currencyOrd);
Currency c = getCurrency(code);
if (null == c) {
throw new SolrException
(SolrException.ErrorCode.SERVER_ERROR,
"Currency code of document is not supported by this JVM: "+code);
}
return c;
}
@Override
public boolean exists(int doc) throws IOException {
return amounts.exists(doc);
}
@Override
public long longVal(int doc) throws IOException {
long amount = amounts.longVal(doc);
// bail fast using whatever amounts defaults to if no value
// (if we don't do this early, currencyOrd may be < 0,
// causing index bounds exception
if ( ! exists(doc) ) {
return amount;
}
if (!initializedCache) {
for (int i = 0; i < fractionDigitCache.length; i++) {
fractionDigitCache[i] = -1;
}
initializedCache = true;
}
int currencyOrd = currencies.ordVal(doc);
if (currencyOrd == targetCurrencyOrd) {
return amount;
}
double exchangeRate;
int sourceFractionDigits;
if (targetFractionDigits == -1) {
targetFractionDigits = targetCurrency.getDefaultFractionDigits();
}
if (currencyOrd < MAX_CURRENCIES_TO_CACHE) {
exchangeRate = exchangeRateCache[currencyOrd];
if (exchangeRate <= 0.0) {
String sourceCurrencyCode = getDocCurrencyCode(doc, currencyOrd);
exchangeRate = exchangeRateCache[currencyOrd] = provider.getExchangeRate(sourceCurrencyCode, targetCurrency.getCurrencyCode());
}
sourceFractionDigits = fractionDigitCache[currencyOrd];
if (sourceFractionDigits == -1) {
sourceFractionDigits = fractionDigitCache[currencyOrd] = getDocCurrency(doc, currencyOrd).getDefaultFractionDigits();
}
} else {
Currency source = getDocCurrency(doc, currencyOrd);
exchangeRate = provider.getExchangeRate(source.getCurrencyCode(), targetCurrency.getCurrencyCode());
sourceFractionDigits = source.getDefaultFractionDigits();
}
return CurrencyValue.convertAmount(exchangeRate, sourceFractionDigits, amount, targetFractionDigits);
}
@Override
public int intVal(int doc) throws IOException {
return (int) longVal(doc);
}
@Override
public double doubleVal(int doc) throws IOException {
return (double) longVal(doc);
}
@Override
public float floatVal(int doc) throws IOException {
return (float) longVal(doc);
}
@Override
public String strVal(int doc) throws IOException {
return Long.toString(longVal(doc));
}
@Override
public String toString(int doc) throws IOException {
return name() + '(' + amounts.toString(doc) + ',' + currencies.toString(doc) + ')';
}
};
}
public String name() {
return "rawcurrency";
}
@Override
public String description() {
return name() + "(" + sf.getName() +
",target="+targetCurrency.getCurrencyCode()+")";
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
RawCurrencyValueSource that = (RawCurrencyValueSource) o;
return !(amountValues != null ? !amountValues.equals(that.amountValues) : that.amountValues != null) &&
!(currencyValues != null ? !currencyValues.equals(that.currencyValues) : that.currencyValues != null) &&
!(targetCurrency != null ? !targetCurrency.equals(that.targetCurrency) : that.targetCurrency != null);
}
@Override
public int hashCode() {
int result = targetCurrency != null ? targetCurrency.hashCode() : 0;
result = 31 * result + (currencyValues != null ? currencyValues.hashCode() : 0);
result = 31 * result + (amountValues != null ? amountValues.hashCode() : 0);
return result;
}
}
}