From bf2be9b35a9d67d5329052314f5199e9ec5f1437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Fri, 9 Mar 2012 22:40:06 +0000 Subject: [PATCH] SOLR-2202: Money/Currency FieldType git-svn-id: https://svn.apache.org/repos/asf/lucene/dev/trunk@1299083 13f79535-47bb-0310-9956-ffa450edef68 --- solr/CHANGES.txt | 3 + .../org/apache/solr/schema/CurrencyField.java | 751 ++++++++++++++++++ .../solr/schema/ExchangeRateProvider.java | 69 ++ .../src/test-files/solr/conf/currency.xml | 34 + solr/core/src/test-files/solr/conf/schema.xml | 7 + .../apache/solr/cloud/AbstractZkTestCase.java | 1 + .../apache/solr/schema/CurrencyFieldTest.java | 211 +++++ .../solr/schema/MockExchangeRateProvider.java | 81 ++ solr/example/exampledocs/money.xml | 65 ++ solr/example/solr/conf/currency.xml | 67 ++ solr/example/solr/conf/schema.xml | 15 +- solr/example/solr/conf/velocity/doc.vm | 2 +- 12 files changed, 1304 insertions(+), 2 deletions(-) create mode 100644 solr/core/src/java/org/apache/solr/schema/CurrencyField.java create mode 100644 solr/core/src/java/org/apache/solr/schema/ExchangeRateProvider.java create mode 100644 solr/core/src/test-files/solr/conf/currency.xml create mode 100644 solr/core/src/test/org/apache/solr/schema/CurrencyFieldTest.java create mode 100644 solr/core/src/test/org/apache/solr/schema/MockExchangeRateProvider.java create mode 100644 solr/example/exampledocs/money.xml create mode 100644 solr/example/solr/conf/currency.xml diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt index 39a0f9249ef..7f7d3be8c10 100644 --- a/solr/CHANGES.txt +++ b/solr/CHANGES.txt @@ -234,6 +234,9 @@ New Features * SOLR-2898: Support grouped faceting. (Martijn van Groningen) +* SOLR-2202: Currency FieldType, whith support for currencies and exchange rates + (Greg Fodor & Andrew Morrison via janhoy, rmuir, Uwe Schindler) + Optimizations ---------------------- diff --git a/solr/core/src/java/org/apache/solr/schema/CurrencyField.java b/solr/core/src/java/org/apache/solr/schema/CurrencyField.java new file mode 100644 index 00000000000..5bdbe6ad35b --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/CurrencyField.java @@ -0,0 +1,751 @@ +package org.apache.solr.schema; +/** + * 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. + */ + +import org.apache.lucene.index.AtomicReaderContext; +import org.apache.lucene.index.IndexableField; +import org.apache.lucene.queries.function.FunctionValues; +import org.apache.lucene.queries.function.ValueSource; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.SortField; +import org.apache.solr.common.ResourceLoader; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.SolrException.ErrorCode; +import org.apache.solr.response.TextResponseWriter; +import org.apache.solr.response.XMLWriter; +import org.apache.solr.search.QParser; +import org.apache.solr.search.SolrConstantScoreQuery; +import org.apache.solr.search.function.ValueSourceRangeFilter; +import org.apache.solr.util.plugin.ResourceLoaderAware; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Currency; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Field type for support of monetary values. + *

+ * See http://wiki.apache.org/solr/CurrencyField + */ +public class CurrencyField extends FieldType implements SchemaAware, ResourceLoaderAware { + protected static final String PARAM_DEFAULT_CURRENCY = "defaultCurrency"; + protected static final String PARAM_RATE_PROVIDER_CLASS = "providerClass"; + protected static final String DEFAULT_RATE_PROVIDER_CLASS = "org.apache.solr.schema.FileExchangeRateProvider"; + protected static final String DEFAULT_DEFAULT_CURRENCY = "USD"; + protected static final String FIELD_SUFFIX_AMOUNT_RAW = "_amount_raw"; + protected static final String FIELD_SUFFIX_CURRENCY = "_currency"; + protected static final String FIELD_TYPE_CURRENCY = "string"; + protected static final String FIELD_TYPE_AMOUNT_RAW = "tlong"; + + private IndexSchema schema; + private String exchangeRateProviderClass; + private String defaultCurrency; + private ExchangeRateProvider provider; + public static Logger log = LoggerFactory.getLogger(CurrencyField.class); + + @Override + protected void init(IndexSchema schema, Map args) { + super.init(schema, args); + this.schema = schema; + this.exchangeRateProviderClass = args.get(PARAM_RATE_PROVIDER_CLASS); + this.defaultCurrency = args.get(PARAM_DEFAULT_CURRENCY); + + if (this.defaultCurrency == null) { + this.defaultCurrency = DEFAULT_DEFAULT_CURRENCY; + } + + if (this.exchangeRateProviderClass == null) { + this.exchangeRateProviderClass = DEFAULT_RATE_PROVIDER_CLASS; + } + + if (java.util.Currency.getInstance(this.defaultCurrency) == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Invalid currency code " + this.defaultCurrency); + } + + args.remove(PARAM_RATE_PROVIDER_CLASS); + args.remove(PARAM_DEFAULT_CURRENCY); + + try { + // TODO: Are we using correct classloader? + Class c = Class.forName(exchangeRateProviderClass); + Object clazz = c.newInstance(); + if (clazz instanceof ExchangeRateProvider) { + provider = (ExchangeRateProvider) clazz; + provider.init(args); + } else { + throw new SolrException(ErrorCode.BAD_REQUEST, "exchangeRateProvider "+exchangeRateProviderClass+" needs to implement ExchangeRateProvider"); + } + } catch (Exception e) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Error instansiating exhange rate provider "+exchangeRateProviderClass+". Please check your FieldType configuration", e); + } + } + + @Override + public boolean isPolyField() { + return true; + } + + @Override + public IndexableField[] createFields(SchemaField field, Object externalVal, float boost) { + CurrencyValue value = CurrencyValue.parse(externalVal.toString(), defaultCurrency); + + IndexableField[] f = new IndexableField[field.stored() ? 3 : 2]; + f[0] = getAmountField(field).createField(String.valueOf(value.getAmount()), boost); + f[1] = getCurrencyField(field).createField(value.getCurrencyCode(), boost); + + if (field.stored()) { + org.apache.lucene.document.FieldType customType = new org.apache.lucene.document.FieldType(); + customType.setStored(true); + String storedValue = externalVal.toString().trim(); + if (storedValue.indexOf(",") < 0) { + storedValue += "," + defaultCurrency; + } + f[2] = createField(field.getName(), storedValue, customType, boost); + } + + return f; + } + + private SchemaField getAmountField(SchemaField field) { + return schema.getField(field.getName() + POLY_FIELD_SEPARATOR + FIELD_SUFFIX_AMOUNT_RAW); + } + + private SchemaField getCurrencyField(SchemaField field) { + return schema.getField(field.getName() + POLY_FIELD_SEPARATOR + FIELD_SUFFIX_CURRENCY); + } + + private void createDynamicCurrencyField(String suffix, String fieldType) { + String name = "*" + POLY_FIELD_SEPARATOR + suffix; + Map props = new HashMap(); + props.put("indexed", "true"); + props.put("stored", "false"); + props.put("multiValued", "false"); + org.apache.solr.schema.FieldType type = schema.getFieldTypeByName(fieldType); + int p = SchemaField.calcProps(name, type, props); + schema.registerDynamicField(SchemaField.create(name, type, p, null)); + } + + /** + * When index schema is informed, add dynamic fields. + * + * @param indexSchema The index schema. + */ + public void inform(IndexSchema indexSchema) { + // TODO: Should we allow configurable field-types or in another way not be dependent on static type names types in schema? + createDynamicCurrencyField(FIELD_SUFFIX_CURRENCY, FIELD_TYPE_CURRENCY); + createDynamicCurrencyField(FIELD_SUFFIX_AMOUNT_RAW, FIELD_TYPE_AMOUNT_RAW); + } + + /** + * Load the currency config when resource loader initialized. + * + * @param resourceLoader The resource loader. + */ + 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 getRangeQuery(parser, field, valueDefault, valueDefault, true, true); + } + + @Override + public Query getRangeQuery(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.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 getRangeQuery(parser, field, p1, p2, minInclusive, maxInclusive); + } + + public Query getRangeQuery(QParser parser, SchemaField field, final CurrencyValue p1, final CurrencyValue p2, final boolean minInclusive, final boolean maxInclusive) { + String currencyCode = p1.getCurrencyCode(); + final CurrencyValueSource vs = new CurrencyValueSource(field, currencyCode, parser); + + return new SolrConstantScoreQuery(new ValueSourceRangeFilter(vs, + p1.getAmount() + "", p2.getAmount() + "", minInclusive, maxInclusive)); + } + + @Override + public SortField getSortField(SchemaField field, boolean reverse) { + try { + // Convert all values to default currency for sorting. + return (new CurrencyValueSource(field, defaultCurrency, null)).getSortField(reverse); + } catch (IOException e) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e); + } + } + + public void write(XMLWriter xmlWriter, String name, IndexableField field) throws IOException { + xmlWriter.writeStr(name, field.stringValue(), false); + } + + @Override + public void write(TextResponseWriter writer, String name, IndexableField field) throws IOException { + writer.writeStr(name, field.stringValue(), false); + } + + public ExchangeRateProvider getProvider() { + return provider; + } + + class CurrencyValueSource extends ValueSource { + private static final long serialVersionUID = 1L; + private String targetCurrencyCode; + private ValueSource currencyValues; + private ValueSource amountValues; + private final SchemaField sf; + + public CurrencyValueSource(SchemaField sfield, String targetCurrencyCode, QParser parser) { + this.sf = sfield; + this.targetCurrencyCode = targetCurrencyCode; + + SchemaField amountField = schema.getField(sf.getName() + POLY_FIELD_SEPARATOR + FIELD_SUFFIX_AMOUNT_RAW); + SchemaField currencyField = schema.getField(sf.getName() + POLY_FIELD_SEPARATOR + FIELD_SUFFIX_CURRENCY); + + currencyValues = currencyField.getType().getValueSource(currencyField, parser); + amountValues = amountField.getType().getValueSource(amountField, parser); + } + + public FunctionValues getValues(Map context, AtomicReaderContext reader) throws IOException { + final FunctionValues amounts = amountValues.getValues(context, reader); + final FunctionValues currencies = currencyValues.getValues(context, reader); + + return new FunctionValues() { + private 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) { + 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(targetCurrencyCode)) { + targetCurrencyOrd = currencyOrd; + } + + return currency; + } else { + return currencies.strVal(doc); + } + } + + public long longVal(int doc) { + if (!initializedCache) { + for (int i = 0; i < fractionDigitCache.length; i++) { + fractionDigitCache[i] = -1; + } + + initializedCache = true; + } + + long amount = amounts.longVal(doc); + int currencyOrd = currencies.ordVal(doc); + + if (currencyOrd == targetCurrencyOrd) { + return amount; + } + + double exchangeRate; + int sourceFractionDigits; + + if (targetFractionDigits == -1) { + targetFractionDigits = Currency.getInstance(targetCurrencyCode).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, targetCurrencyCode); + } + + sourceFractionDigits = fractionDigitCache[currencyOrd]; + + if (sourceFractionDigits == -1) { + String sourceCurrencyCode = getDocCurrencyCode(doc, currencyOrd); + sourceFractionDigits = fractionDigitCache[currencyOrd] = Currency.getInstance(sourceCurrencyCode).getDefaultFractionDigits(); + } + } else { + String sourceCurrencyCode = getDocCurrencyCode(doc, currencyOrd); + exchangeRate = provider.getExchangeRate(sourceCurrencyCode, targetCurrencyCode); + sourceFractionDigits = Currency.getInstance(sourceCurrencyCode).getDefaultFractionDigits(); + } + + return CurrencyValue.convertAmount(exchangeRate, sourceFractionDigits, amount, targetFractionDigits); + } + + public int intVal(int doc) { + return (int) longVal(doc); + } + + public double doubleVal(int doc) { + return (double) longVal(doc); + } + + public float floatVal(int doc) { + return (float) longVal(doc); + } + + public String strVal(int doc) { + return Long.toString(longVal(doc)); + } + + public String toString(int doc) { + return name() + '(' + amounts.toString(doc) + ',' + currencies.toString(doc) + ')'; + } + }; + } + + public String name() { + return "currency"; + } + + @Override + public String description() { + return name() + "(" + sf.getName() + ")"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CurrencyValueSource that = (CurrencyValueSource) o; + + return !(amountValues != null ? !amountValues.equals(that.amountValues) : that.amountValues != null) && + !(currencyValues != null ? !currencyValues.equals(that.currencyValues) : that.currencyValues != null) && + !(targetCurrencyCode != null ? !targetCurrencyCode.equals(that.targetCurrencyCode) : that.targetCurrencyCode != null); + + } + + @Override + public int hashCode() { + int result = targetCurrencyCode != null ? targetCurrencyCode.hashCode() : 0; + result = 31 * result + (currencyValues != null ? currencyValues.hashCode() : 0); + result = 31 * result + (amountValues != null ? amountValues.hashCode() : 0); + return result; + } + } +} + +/** + * Configuration for currency. Provides currency exchange rates. + */ +class FileExchangeRateProvider implements ExchangeRateProvider { + public static Logger log = LoggerFactory.getLogger(FileExchangeRateProvider.class); + protected static final String PARAM_CURRENCY_CONFIG = "currencyConfig"; + + // Exchange rate map, maps Currency Code -> Currency Code -> Rate + private Map> rates = new HashMap>(); + + private String currencyConfigFile; + private ResourceLoader loader; + + /** + * Returns the currently known exchange rate between two currencies. If a direct rate has been loaded, + * it is used. Otherwise, if a rate is known to convert the target currency to the source, the inverse + * exchange rate is computed. + * + * @param sourceCurrencyCode The source currency being converted from. + * @param targetCurrencyCode The target currency being converted to. + * @return The exchange rate. + * @throws an exception if the requested currency pair cannot be found + */ + public double getExchangeRate(String sourceCurrencyCode, String targetCurrencyCode) { + if (sourceCurrencyCode == null || targetCurrencyCode == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Cannot get exchange rate; currency was null."); + } + + if (sourceCurrencyCode.equals(targetCurrencyCode)) { + return 1.0; + } + + Double directRate = lookupRate(sourceCurrencyCode, targetCurrencyCode); + + if (directRate != null) { + return directRate; + } + + Double symmetricRate = lookupRate(targetCurrencyCode, sourceCurrencyCode); + + if (symmetricRate != null) { + return 1.0 / symmetricRate; + } + + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No available conversion rate between " + sourceCurrencyCode + " to " + targetCurrencyCode); + } + + /** + * Looks up the current known rate, if any, between the source and target currencies. + * + * @param sourceCurrencyCode The source currency being converted from. + * @param targetCurrencyCode The target currency being converted to. + * @return The exchange rate, or null if no rate has been registered. + */ + private Double lookupRate(String sourceCurrencyCode, String targetCurrencyCode) { + Map rhs = rates.get(sourceCurrencyCode); + + if (rhs != null) { + return rhs.get(targetCurrencyCode); + } + + return null; + } + + /** + * Registers the specified exchange rate. + * + * @param ratesMap The map to add rate to + * @param sourceCurrencyCode The source currency. + * @param targetCurrencyCode The target currency. + * @param rate The known exchange rate. + */ + private void addRate(Map> ratesMap, String sourceCurrencyCode, String targetCurrencyCode, double rate) { + Map rhs = ratesMap.get(sourceCurrencyCode); + + if (rhs == null) { + rhs = new HashMap(); + ratesMap.put(sourceCurrencyCode, rhs); + } + + rhs.put(targetCurrencyCode, rate); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FileExchangeRateProvider that = (FileExchangeRateProvider) o; + + return !(rates != null ? !rates.equals(that.rates) : that.rates != null); + } + + @Override + public int hashCode() { + return rates != null ? rates.hashCode() : 0; + } + + public String toString() { + return "["+this.getClass().getName()+" : " + rates.size() + " rates.]"; + } + + @Override + public String[] listAvailableCurrencies() { + List pairs = new ArrayList(); + for(String from : rates.keySet()) { + for(String to : rates.get(from).keySet()) { + pairs.add(from+","+to); + } + } + return pairs.toArray(new String[1]); + } + + @Override + public boolean reload() throws SolrException { + InputStream is = null; + Map> tmpRates = new HashMap>(); + try { + log.info("Reloading exchange rates from file "+this.currencyConfigFile); + + is = loader.openResource(currencyConfigFile); + javax.xml.parsers.DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + try { + dbf.setXIncludeAware(true); + dbf.setNamespaceAware(true); + } catch (UnsupportedOperationException e) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "XML parser doesn't support XInclude option", e); + } + + try { + Document doc = dbf.newDocumentBuilder().parse(is); + XPathFactory xpathFactory = XPathFactory.newInstance(); + XPath xpath = xpathFactory.newXPath(); + + // Parse exchange rates. + NodeList nodes = (NodeList) xpath.evaluate("/currencyConfig/rates/rate", doc, XPathConstants.NODESET); + + for (int i = 0; i < nodes.getLength(); i++) { + Node rateNode = nodes.item(i); + NamedNodeMap attributes = rateNode.getAttributes(); + Node from = attributes.getNamedItem("from"); + Node to = attributes.getNamedItem("to"); + Node rate = attributes.getNamedItem("rate"); + + if (from == null || to == null || rate == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Exchange rate missing attributes (required: from, to, rate) " + rateNode); + } + + String fromCurrency = from.getNodeValue(); + String toCurrency = to.getNodeValue(); + Double exchangeRate; + + if (java.util.Currency.getInstance(fromCurrency) == null || + java.util.Currency.getInstance(toCurrency) == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Could not find from currency specified in exchange rate: " + rateNode); + } + + try { + exchangeRate = Double.parseDouble(rate.getNodeValue()); + } catch (NumberFormatException e) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Could not parse exchange rate: " + rateNode, e); + } + + addRate(tmpRates, fromCurrency, toCurrency, exchangeRate); + } + } catch (SAXException e) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Error parsing currency config.", e); + } catch (IOException e) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Error parsing currency config.", e); + } catch (ParserConfigurationException e) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Error parsing currency config.", e); + } catch (XPathExpressionException e) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Error parsing currency config.", e); + } + } catch (IOException e) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Error while opening Currency configuration file "+currencyConfigFile, e); + } finally { + try { + if (is != null) { + is.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + // Atomically swap in the new rates map, if it loaded successfully + this.rates = tmpRates; + return true; + } + + @Override + public void init(Map params) throws SolrException { + this.currencyConfigFile = params.get(PARAM_CURRENCY_CONFIG); + if(currencyConfigFile == null) { + throw new SolrException(ErrorCode.NOT_FOUND, "Missing required configuration "+PARAM_CURRENCY_CONFIG); + } + + // Removing config params custom to us + params.remove(PARAM_CURRENCY_CONFIG); + } + + @Override + public void inform(ResourceLoader loader) throws SolrException { + if(loader == null) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Needs ResourceLoader in order to load config file"); + } + this.loader = loader; + reload(); + } +} + +/** + * Represents a Currency field value, which includes a long amount and ISO currency code. + */ +class CurrencyValue { + private long amount; + private String currencyCode; + + /** + * Constructs a new currency value. + * + * @param amount The amount. + * @param currencyCode The currency code. + */ + public CurrencyValue(long amount, String currencyCode) { + this.amount = amount; + this.currencyCode = currencyCode; + } + + /** + * Constructs a new currency value by parsing the specific input. + *

+ * Currency values are expected to be in the format <amount>,<currency code>, + * for example, "500,USD" would represent 5 U.S. Dollars. + *

+ * If no currency code is specified, the default is assumed. + * + * @param externalVal The value to parse. + * @param defaultCurrency The default currency. + * @return The parsed CurrencyValue. + */ + public static CurrencyValue parse(String externalVal, String defaultCurrency) { + String amount = externalVal; + String code = defaultCurrency; + + if (externalVal.contains(",")) { + String[] amountAndCode = externalVal.split(","); + amount = amountAndCode[0]; + code = amountAndCode[1]; + } + + Currency currency = java.util.Currency.getInstance(code); + + if (currency == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Invalid currency code " + code); + } + + try { + double value = Double.parseDouble(amount); + long currencyValue = Math.round(value * Math.pow(10.0, currency.getDefaultFractionDigits())); + + return new CurrencyValue(currencyValue, code); + } catch (NumberFormatException e) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e); + } + } + + /** + * The amount of the CurrencyValue. + * + * @return The amount. + */ + public long getAmount() { + return amount; + } + + /** + * The ISO currency code of the CurrencyValue. + * + * @return The currency code. + */ + public String getCurrencyCode() { + return currencyCode; + } + + /** + * Performs a currency conversion & unit conversion. + * + * @param exchangeRates Exchange rates to apply. + * @param sourceCurrencyCode The source currency code. + * @param sourceAmount The source amount. + * @param targetCurrencyCode The target currency code. + * @return The converted indexable units after the exchange rate and currency fraction digits are applied. + */ + public static long convertAmount(ExchangeRateProvider exchangeRates, String sourceCurrencyCode, long sourceAmount, String targetCurrencyCode) { + double exchangeRate = exchangeRates.getExchangeRate(sourceCurrencyCode, targetCurrencyCode); + return convertAmount(exchangeRate, sourceCurrencyCode, sourceAmount, targetCurrencyCode); + } + + /** + * Performs a currency conversion & unit conversion. + * + * @param exchangeRate Exchange rate to apply. + * @param sourceFractionDigits The fraction digits of the source. + * @param sourceAmount The source amount. + * @param targetFractionDigits The fraction digits of the target. + * @return The converted indexable units after the exchange rate and currency fraction digits are applied. + */ + public static long convertAmount(final double exchangeRate, final int sourceFractionDigits, final long sourceAmount, final int targetFractionDigits) { + int digitDelta = targetFractionDigits - sourceFractionDigits; + double value = ((double) sourceAmount * exchangeRate); + + if (digitDelta != 0) { + if (digitDelta < 0) { + for (int i = 0; i < -digitDelta; i++) { + value *= 0.1; + } + } else { + for (int i = 0; i < digitDelta; i++) { + value *= 10.0; + } + } + } + + return (long) value; + } + + /** + * Performs a currency conversion & unit conversion. + * + * @param exchangeRate Exchange rate to apply. + * @param sourceCurrencyCode The source currency code. + * @param sourceAmount The source amount. + * @param targetCurrencyCode The target currency code. + * @return The converted indexable units after the exchange rate and currency fraction digits are applied. + */ + public static long convertAmount(double exchangeRate, String sourceCurrencyCode, long sourceAmount, String targetCurrencyCode) { + if (targetCurrencyCode.equals(sourceCurrencyCode)) { + return sourceAmount; + } + + int sourceFractionDigits = Currency.getInstance(sourceCurrencyCode).getDefaultFractionDigits(); + Currency targetCurrency = Currency.getInstance(targetCurrencyCode); + int targetFractionDigits = targetCurrency.getDefaultFractionDigits(); + return convertAmount(exchangeRate, sourceFractionDigits, sourceAmount, targetFractionDigits); + } + + /** + * Returns a new CurrencyValue that is the conversion of this CurrencyValue to the specified currency. + * + * @param exchangeRates The exchange rate provider. + * @param targetCurrencyCode The target currency code to convert this CurrencyValue to. + * @return The converted CurrencyValue. + */ + public CurrencyValue convertTo(ExchangeRateProvider exchangeRates, String targetCurrencyCode) { + return new CurrencyValue(convertAmount(exchangeRates, this.getCurrencyCode(), this.getAmount(), targetCurrencyCode), targetCurrencyCode); + } + + public String toString() { + return String.valueOf(amount) + "," + currencyCode; + } +} \ No newline at end of file diff --git a/solr/core/src/java/org/apache/solr/schema/ExchangeRateProvider.java b/solr/core/src/java/org/apache/solr/schema/ExchangeRateProvider.java new file mode 100644 index 00000000000..eb2fc6c3009 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/ExchangeRateProvider.java @@ -0,0 +1,69 @@ +package org.apache.solr.schema; +/** + * 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. + */ + +import java.util.Map; + +import org.apache.solr.common.ResourceLoader; +import org.apache.solr.common.SolrException; + +/** + * Interface for providing pluggable exchange rate providers to @CurrencyField + */ +public interface ExchangeRateProvider { + /** + * Get the exchange rate betwen the two given currencies + * @param sourceCurrencyCode + * @param targetCurrencyCode + * @return the exhange rate as a double + * @throws exception if the rate is not defined in the provider + */ + public double getExchangeRate(String sourceCurrencyCode, String targetCurrencyCode) throws SolrException; + + /** + * List all configured currency code pairs + * @return a string array of ISO 4217 currency codes on the format + * ["SRC,DST", "SRC,DST"...] + */ + public String[] listAvailableCurrencies(); + + /** + * Ask the currency provider to explicitly reload/refresh its configuration. + * If this does not make sense for a particular provider, simply do nothing + * @throws SolrException if there is a problem reloading + * @return true if reload of rates succeeded, else false + */ + public boolean reload() throws SolrException; + + /** + * Initializes the provider by passing in a set of key/value configs as a map. + * Note that the map also contains other fieldType parameters, so make sure to + * avoid name clashes. + *

+ * Important: Custom config params must be removed from the map before returning + * @param args a @Map of key/value config params to initialize the provider + */ + public void init(Map args); + + /** + * Passes a ResourceLoader, used to read config files from e.g. ZooKeeper. + * Implementations not needing resource loader can implement this as NOOP. + *

Typically called after init + * @param loader a @ResourceLoader instance + */ + public void inform(ResourceLoader loader) throws SolrException; +} diff --git a/solr/core/src/test-files/solr/conf/currency.xml b/solr/core/src/test-files/solr/conf/currency.xml new file mode 100644 index 00000000000..f74f6e96399 --- /dev/null +++ b/solr/core/src/test-files/solr/conf/currency.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + diff --git a/solr/core/src/test-files/solr/conf/schema.xml b/solr/core/src/test-files/solr/conf/schema.xml index 7cb97e59d9e..c6b286768c5 100644 --- a/solr/core/src/test-files/solr/conf/schema.xml +++ b/solr/core/src/test-files/solr/conf/schema.xml @@ -394,6 +394,10 @@ + + + + @@ -467,6 +471,9 @@ + + + diff --git a/solr/core/src/test/org/apache/solr/cloud/AbstractZkTestCase.java b/solr/core/src/test/org/apache/solr/cloud/AbstractZkTestCase.java index 3db5d1cfe90..9c99f96f35d 100644 --- a/solr/core/src/test/org/apache/solr/cloud/AbstractZkTestCase.java +++ b/solr/core/src/test/org/apache/solr/cloud/AbstractZkTestCase.java @@ -90,6 +90,7 @@ public abstract class AbstractZkTestCase extends SolrTestCaseJ4 { putConfig(zkClient, "solrconfig.xml"); putConfig(zkClient, "stopwords.txt"); putConfig(zkClient, "protwords.txt"); + putConfig(zkClient, "currency.xml"); putConfig(zkClient, "mapping-ISOLatin1Accent.txt"); putConfig(zkClient, "old_synonyms.txt"); putConfig(zkClient, "synonyms.txt"); diff --git a/solr/core/src/test/org/apache/solr/schema/CurrencyFieldTest.java b/solr/core/src/test/org/apache/solr/schema/CurrencyFieldTest.java new file mode 100644 index 00000000000..bfd190e59dc --- /dev/null +++ b/solr/core/src/test/org/apache/solr/schema/CurrencyFieldTest.java @@ -0,0 +1,211 @@ +package org.apache.solr.schema; +/** + * 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. + */ + +import org.apache.lucene.index.IndexableField; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.core.SolrCore; +import org.junit.BeforeClass; +import org.junit.Ignore; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; + +/** + * Tests currency field type. + */ +public class CurrencyFieldTest extends SolrTestCaseJ4 { + @BeforeClass + public static void beforeClass() throws Exception { + initCore("solrconfig.xml", "schema.xml"); + } + + @Test + public void testCurrencySchema() throws Exception { + IndexSchema schema = h.getCore().getSchema(); + + SchemaField amount = schema.getField("amount"); + assertNotNull(amount); + assertTrue(amount.isPolyField()); + + SchemaField[] dynFields = schema.getDynamicFieldPrototypes(); + boolean seenCurrency = false; + boolean seenAmount = false; + + for (SchemaField dynField : dynFields) { + if (dynField.getName().equals("*" + FieldType.POLY_FIELD_SEPARATOR + CurrencyField.FIELD_SUFFIX_CURRENCY)) { + seenCurrency = true; + } + + if (dynField.getName().equals("*" + FieldType.POLY_FIELD_SEPARATOR + CurrencyField.FIELD_SUFFIX_AMOUNT_RAW)) { + seenAmount = true; + } + } + + assertTrue("Didn't find the expected currency code dynamic field", seenCurrency); + assertTrue("Didn't find the expected value dynamic field", seenAmount); + } + + @Test + public void testCurrencyFieldType() throws Exception { + SolrCore core = h.getCore(); + IndexSchema schema = core.getSchema(); + SchemaField amount = schema.getField("amount"); + assertNotNull(amount); + assertTrue("amount is not a poly field", amount.isPolyField()); + FieldType tmp = amount.getType(); + assertTrue(tmp instanceof CurrencyField); + String currencyValue = "1.50,EUR"; + IndexableField[] fields = amount.createFields(currencyValue, 2); + assertEquals(fields.length, 3); + + // First field is currency code, second is value, third is stored. + for (int i = 0; i < 3; i++) { + boolean hasValue = fields[i].readerValue() != null + || fields[i].numericValue() != null + || fields[i].stringValue() != null; + assertTrue("Doesn't have a value: " + fields[i], hasValue); + } + + assertEquals(schema.getFieldTypeByName("string").toExternal(fields[2]), "1.50,EUR"); + + // A few tests on the provider directly + ExchangeRateProvider p = ((CurrencyField) tmp).getProvider(); + String[] available = p.listAvailableCurrencies(); + assert(available.length == 5); + assert(p.reload() == true); + assert(p.getExchangeRate("USD", "EUR") == 2.5); + } + + @Test + public void testCurrencyRangeSearch() throws Exception { + for (int i = 1; i <= 10; i++) { + assertU(adoc("id", "" + i, "amount", i + ",USD")); + } + + assertU(commit()); + + assertQ(req("fl", "*,score", "q", + "amount:[2.00,USD TO 5.00,USD]"), + "//*[@numFound='4']"); + + assertQ(req("fl", "*,score", "q", + "amount:[0.50,USD TO 1.00,USD]"), + "//*[@numFound='1']"); + + assertQ(req("fl", "*,score", "q", + "amount:[24.00,USD TO 25.00,USD]"), + "//*[@numFound='0']"); + + // "GBP" currency code is 1/2 of a USD dollar, for testing. + assertQ(req("fl", "*,score", "q", + "amount:[0.50,GBP TO 1.00,GBP]"), + "//*[@numFound='2']"); + + // "EUR" currency code is 2.5X of a USD dollar, for testing. + assertQ(req("fl", "*,score", "q", + "amount:[24.00,EUR TO 25.00,EUR]"), + "//*[@numFound='1']"); + + // Slight asymmetric rate should work. + assertQ(req("fl", "*,score", "q", + "amount:[24.99,EUR TO 25.01,EUR]"), + "//*[@numFound='1']"); + } + + @Test + public void testCurrencyPointQuery() throws Exception { + assertU(adoc("id", "" + 1, "amount", "10.00,USD")); + assertU(adoc("id", "" + 2, "amount", "15.00,EUR")); + assertU(commit()); + assertQ(req("fl", "*,score", "q", "amount:10.00,USD"), "//int[@name='id']='1'"); + assertQ(req("fl", "*,score", "q", "amount:9.99,USD"), "//*[@numFound='0']"); + assertQ(req("fl", "*,score", "q", "amount:10.01,USD"), "//*[@numFound='0']"); + assertQ(req("fl", "*,score", "q", "amount:15.00,EUR"), "//int[@name='id']='2'"); + assertQ(req("fl", "*,score", "q", "amount:7.50,USD"), "//int[@name='id']='2'"); + assertQ(req("fl", "*,score", "q", "amount:7.49,USD"), "//*[@numFound='0']"); + assertQ(req("fl", "*,score", "q", "amount:7.51,USD"), "//*[@numFound='0']"); + } + + @Ignore + public void testPerformance() throws Exception { + Random r = new Random(); + int initDocs = 200000; + + for (int i = 1; i <= initDocs; i++) { + assertU(adoc("id", "" + i, "amount", (r.nextInt(10) + 1.00) + ",USD")); + if (i % 1000 == 0) + System.out.println(i); + } + + assertU(commit()); + for (int i = 0; i < 1000; i++) { + double lower = r.nextInt(10) + 1.00; + assertQ(req("fl", "*,score", "q", "amount:[" + lower + ",USD TO " + (lower + 10.00) + ",USD]"), "//*"); + assertQ(req("fl", "*,score", "q", "amount:[" + lower + ",EUR TO " + (lower + 10.00) + ",EUR]"), "//*"); + } + + for (int j = 0; j < 3; j++) { + long t1 = System.currentTimeMillis(); + for (int i = 0; i < 1000; i++) { + double lower = r.nextInt(10) + 1.00; + assertQ(req("fl", "*,score", "q", "amount:[" + lower + ",USD TO " + (lower + (9.99 - (j * 0.01))) + ",USD]"), "//*"); + } + + System.out.println(System.currentTimeMillis() - t1); + } + + System.out.println("---"); + + for (int j = 0; j < 3; j++) { + long t1 = System.currentTimeMillis(); + for (int i = 0; i < 1000; i++) { + double lower = r.nextInt(10) + 1.00; + assertQ(req("fl", "*,score", "q", "amount:[" + lower + ",EUR TO " + (lower + (9.99 - (j * 0.01))) + ",EUR]"), "//*"); + } + + System.out.println(System.currentTimeMillis() - t1); + } + } + + @Test + public void testCurrencySort() throws Exception { + assertU(adoc("id", "" + 1, "amount", "10.00,USD")); + assertU(adoc("id", "" + 2, "amount", "15.00,EUR")); + assertU(adoc("id", "" + 3, "amount", "7.00,EUR")); + assertU(adoc("id", "" + 4, "amount", "6.00,GBP")); + assertU(adoc("id", "" + 5, "amount", "2.00,GBP")); + assertU(commit()); + + assertQ(req("fl", "*,score", "q", "*:*", "sort", "amount desc", "limit", "1"), "//int[@name='id']='4'"); + assertQ(req("fl", "*,score", "q", "*:*", "sort", "amount asc", "limit", "1"), "//int[@name='id']='3'"); + } + + @Test + public void testMockExchangeRateProvider() throws Exception { + assertU(adoc("id", "1", "mock_amount", "1.00,USD")); + assertU(adoc("id", "2", "mock_amount", "1.00,EUR")); + assertU(adoc("id", "3", "mock_amount", "1.00,NOK")); + assertU(commit()); + + assertQ(req("fl", "*,score", "q", "mock_amount:5.0,NOK"), "//*[@numFound='1']", "//int[@name='id']='1'"); + assertQ(req("fl", "*,score", "q", "mock_amount:1.2,USD"), "//*[@numFound='1']", "//int[@name='id']='2'"); + assertQ(req("fl", "*,score", "q", "mock_amount:0.2,USD"), "//*[@numFound='1']", "//int[@name='id']='3'"); + assertQ(req("fl", "*,score", "q", "mock_amount:99,USD"), "//*[@numFound='0']"); + } +} diff --git a/solr/core/src/test/org/apache/solr/schema/MockExchangeRateProvider.java b/solr/core/src/test/org/apache/solr/schema/MockExchangeRateProvider.java new file mode 100644 index 00000000000..8fa50815c94 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/schema/MockExchangeRateProvider.java @@ -0,0 +1,81 @@ +package org.apache.solr.schema; +/** + * 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. + */ + +import java.util.HashMap; +import java.util.Map; + +import org.apache.solr.common.ResourceLoader; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.SolrException.ErrorCode; + +/** + * Simple mock provider with fixed rates and some assertions + */ +public class MockExchangeRateProvider implements ExchangeRateProvider { + private static Map map = new HashMap(); + static { + map.put("USD,EUR", 0.8); + map.put("EUR,USD", 1.2); + map.put("USD,NOK", 5.0); + map.put("NOK,USD", 0.2); + map.put("EUR,NOK", 10.0); + map.put("NOK,EUR", 0.1); + } + + private boolean gotArgs = false; + private boolean gotLoader = false; + + @Override + public double getExchangeRate(String sourceCurrencyCode, String targetCurrencyCode) { +// System.out.println("***** getExchangeRate("+sourceCurrencyCode+targetCurrencyCode+")"); + if(sourceCurrencyCode.equals(targetCurrencyCode)) return 1.0; + + Double result = map.get(sourceCurrencyCode+","+targetCurrencyCode); + if(result == null) { + throw new SolrException(ErrorCode.NOT_FOUND, "No exchange rate found for the pair "+sourceCurrencyCode+","+targetCurrencyCode); + } + return result; + } + + @Override + public String[] listAvailableCurrencies() { + return map.keySet().toArray(new String[1]); + } + + @Override + public boolean reload() throws SolrException { + assert(gotArgs == true); + assert(gotLoader == true); + return true; + } + + @Override + public void init(Map args) { + assert(args.get("foo").equals("bar")); + gotArgs = true; + args.remove("foo"); + } + + @Override + public void inform(ResourceLoader loader) throws SolrException { + assert(loader != null); + gotLoader = true; + assert(gotArgs == true); + } + +} diff --git a/solr/example/exampledocs/money.xml b/solr/example/exampledocs/money.xml new file mode 100644 index 00000000000..b1b8036c369 --- /dev/null +++ b/solr/example/exampledocs/money.xml @@ -0,0 +1,65 @@ + + + + + + USD + One Dollar + Bank of America + boa + currency + Coins and notes + 1,USD + true + + + + EUR + One Euro + European Union + eu + currency + Coins and notes + 1,EUR + true + + + + GBP + One British Pound + U.K. + uk + currency + Coins and notes + 1,GBP + true + + + + NOK + One Krone + Bank of Norway + nor + currency + Coins and notes + 1,NOK + true + + + + diff --git a/solr/example/solr/conf/currency.xml b/solr/example/solr/conf/currency.xml new file mode 100644 index 00000000000..3a9c58afee8 --- /dev/null +++ b/solr/example/solr/conf/currency.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/solr/example/solr/conf/schema.xml b/solr/example/solr/conf/schema.xml index 26019eab33f..ff7daebc299 100755 --- a/solr/example/solr/conf/schema.xml +++ b/solr/example/solr/conf/schema.xml @@ -455,6 +455,15 @@ --> + + + @@ -920,7 +929,7 @@ - + @@ -933,6 +942,7 @@ + @@ -968,6 +978,9 @@ + + +