diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt index b14d79e0dda..3d1cda24feb 100644 --- a/solr/CHANGES.txt +++ b/solr/CHANGES.txt @@ -84,6 +84,9 @@ New Features * SOLR-11063: Suggesters should accept required freedisk as a hint (noble) +* SOLR-3218: Added range faceting support for CurrencyFieldType. This includes both "facet.range" as well + as json.facet's "type:range" (Andrew Morrison, Jan Høydahl, Vitaliy Zhovtyuk, hossman) + Bug Fixes ---------------------- diff --git a/solr/core/src/java/org/apache/solr/handler/component/RangeFacetRequest.java b/solr/core/src/java/org/apache/solr/handler/component/RangeFacetRequest.java index c2348669b90..8d47a930d6b 100644 --- a/solr/core/src/java/org/apache/solr/handler/component/RangeFacetRequest.java +++ b/solr/core/src/java/org/apache/solr/handler/component/RangeFacetRequest.java @@ -31,8 +31,11 @@ import org.apache.solr.common.params.RequiredSolrParams; import org.apache.solr.common.params.SolrParams; import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; +import org.apache.solr.schema.CurrencyFieldType; +import org.apache.solr.schema.CurrencyValue; import org.apache.solr.schema.DatePointField; import org.apache.solr.schema.DateRangeField; +import org.apache.solr.schema.ExchangeRateProvider; import org.apache.solr.schema.FieldType; import org.apache.solr.schema.IndexSchema; import org.apache.solr.schema.SchemaField; @@ -189,6 +192,8 @@ public class RangeFacetRequest extends FacetComponent.FacetBase { (SolrException.ErrorCode.BAD_REQUEST, "Unable to range facet on Point field of unexpected type:" + this.facetOn); } + } else if (ft instanceof CurrencyFieldType) { + calc = new CurrencyRangeEndpointCalculator(this); } else { throw new SolrException (SolrException.ErrorCode.BAD_REQUEST, @@ -451,12 +456,14 @@ public class RangeFacetRequest extends FacetComponent.FacetBase { this.field = rfr.getSchemaField(); } - public T getComputedEnd() { + /** The Computed End point of all ranges, as an Object of type suitable for direct inclusion in the response data */ + public Object getComputedEnd() { assert computed; return computedEnd; } - public T getStart() { + /** The Start point of all ranges, as an Object of type suitable for direct inclusion in the response data */ + public Object getStart() { assert computed; return start; } @@ -756,6 +763,68 @@ public class RangeFacetRequest extends FacetComponent.FacetBase { } } + private static class CurrencyRangeEndpointCalculator + extends RangeEndpointCalculator { + private String defaultCurrencyCode; + private ExchangeRateProvider exchangeRateProvider; + public CurrencyRangeEndpointCalculator(final RangeFacetRequest rangeFacetRequest) { + super(rangeFacetRequest); + if(!(this.field.getType() instanceof CurrencyFieldType)) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, + "Cannot perform range faceting over non CurrencyField fields"); + } + defaultCurrencyCode = + ((CurrencyFieldType)this.field.getType()).getDefaultCurrency(); + exchangeRateProvider = + ((CurrencyFieldType)this.field.getType()).getProvider(); + } + + @Override + protected Object parseGap(String rawval) throws java.text.ParseException { + return parseVal(rawval).strValue(); + } + + @Override + public String formatValue(CurrencyValue val) { + return val.strValue(); + } + + /** formats the value as a String since {@link CurrencyValue} is not suitable for response writers */ + @Override + public Object getComputedEnd() { + assert computed; + return formatValue(computedEnd); + } + + /** formats the value as a String since {@link CurrencyValue} is not suitable for response writers */ + @Override + public Object getStart() { + assert computed; + return formatValue(start); + } + + @Override + protected CurrencyValue parseVal(String rawval) { + return CurrencyValue.parse(rawval, defaultCurrencyCode); + } + + @Override + public CurrencyValue parseAndAddGap(CurrencyValue value, String gap) { + if(value == null) { + throw new NullPointerException("Cannot perform range faceting on null CurrencyValue"); + } + CurrencyValue gapCurrencyValue = + CurrencyValue.parse(gap, defaultCurrencyCode); + long gapAmount = + CurrencyValue.convertAmount(this.exchangeRateProvider, + gapCurrencyValue.getCurrencyCode(), + gapCurrencyValue.getAmount(), + value.getCurrencyCode()); + return new CurrencyValue(value.getAmount() + gapAmount, + value.getCurrencyCode()); + } + } + /** * Represents a single facet range (or gap) for which the count is to be calculated */ diff --git a/solr/core/src/java/org/apache/solr/schema/CurrencyFieldType.java b/solr/core/src/java/org/apache/solr/schema/CurrencyFieldType.java index a6ba164d1db..97195da243a 100644 --- a/solr/core/src/java/org/apache/solr/schema/CurrencyFieldType.java +++ b/solr/core/src/java/org/apache/solr/schema/CurrencyFieldType.java @@ -89,6 +89,12 @@ public class CurrencyFieldType extends FieldType implements SchemaAware, Resourc 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 args) { super.init(schema, args); @@ -666,164 +672,5 @@ public class CurrencyFieldType extends FieldType implements SchemaAware, Resourc } } - /** - * Represents a Currency field value, which includes a long amount and ISO currency code. - */ - static 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) { - if (externalVal == null) { - return null; - } - String amount = externalVal; - String code = defaultCurrency; - - if (externalVal.contains(",")) { - String[] amountAndCode = externalVal.split(","); - amount = amountAndCode[0]; - code = amountAndCode[1]; - } - - if (amount.equals("*")) { - return null; - } - - Currency currency = getCurrency(code); - - if (currency == null) { - throw new SolrException(ErrorCode.BAD_REQUEST, "Currency code not supported by this JVM: " + 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(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); - } - - @Override - public String toString() { - return String.valueOf(amount) + "," + currencyCode; - } - } } diff --git a/solr/core/src/java/org/apache/solr/schema/CurrencyValue.java b/solr/core/src/java/org/apache/solr/schema/CurrencyValue.java new file mode 100644 index 00000000000..4c43422bed9 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/CurrencyValue.java @@ -0,0 +1,231 @@ +/* + * 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 org.apache.solr.common.SolrException; + +import java.util.Currency; + +/** + * Represents a Currency field value, which includes a long amount and ISO currency code. + */ +public class CurrencyValue implements Comparable { + 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) { + if (externalVal == null) { + return null; + } + String amount = externalVal; + String code = defaultCurrency; + + if (externalVal.contains(",")) { + String[] amountAndCode = externalVal.split(","); + amount = amountAndCode[0]; + code = amountAndCode[1]; + } + + if (amount.equals("*")) { + return null; + } + + Currency currency = CurrencyField.getCurrency(code); + + if (currency == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Currency code not supported by this JVM: " + 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); + } + + /** + * Returns a string representing the currency value such as "3.14,USD" for + * a CurrencyValue of $3.14 USD. + */ + public String strValue() { + int digits = 0; + try { + Currency currency = + Currency.getInstance(this.getCurrencyCode()); + if (currency == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, + "Invalid currency code " + this.getCurrencyCode()); + } + digits = currency.getDefaultFractionDigits(); +} + catch(IllegalArgumentException exception) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, + "Invalid currency code " + this.getCurrencyCode()); + } + + String amount = Long.toString(this.getAmount()); + if (this.getAmount() == 0) { + amount += "000000".substring(0,digits); + } + return + amount.substring(0, amount.length() - digits) + + "." + amount.substring(amount.length() - digits) + + "," + this.getCurrencyCode(); + } + + @Override + public int compareTo(CurrencyValue o) { + if(o == null) { + throw new NullPointerException("Cannot compare CurrencyValue to a null values"); + } + if(!getCurrencyCode().equals(o.getCurrencyCode())) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, + "Cannot compare CurrencyValues when their currencies are not equal"); + } + if(o.getAmount() < getAmount()) { + return 1; + } + if(o.getAmount() == getAmount()) { + return 0; + } + return -1; + } + + @Override + public String toString() { + return strValue(); + } +} diff --git a/solr/core/src/java/org/apache/solr/search/facet/FacetRange.java b/solr/core/src/java/org/apache/solr/search/facet/FacetRange.java index 1176a77d879..09b8ec057a5 100644 --- a/solr/core/src/java/org/apache/solr/search/facet/FacetRange.java +++ b/solr/core/src/java/org/apache/solr/search/facet/FacetRange.java @@ -29,6 +29,9 @@ import org.apache.lucene.util.NumericUtils; import org.apache.solr.common.SolrException; import org.apache.solr.common.params.FacetParams; import org.apache.solr.common.util.SimpleOrderedMap; +import org.apache.solr.schema.CurrencyFieldType; +import org.apache.solr.schema.CurrencyValue; +import org.apache.solr.schema.ExchangeRateProvider; import org.apache.solr.schema.FieldType; import org.apache.solr.schema.PointField; import org.apache.solr.schema.SchemaField; @@ -120,6 +123,14 @@ class FacetRangeProcessor extends FacetProcessor { } } + /** + * Returns a {@link Calc} instance to use for term faceting over a numeric field. + * This metod is unused for range faceting, and exists solely as a helper method for other classes + * + * @param sf A field to facet on, must be of a type such that {@link FieldType#getNumberType} is non null + * @return a Calc instance with {@link Calc#bitsToValue} and {@link Calc#bitsToSortableBits} methods suitable for the specified field. + * @see FacetFieldProcessorByHashDV + */ public static Calc getNumericCalc(SchemaField sf) { Calc calc; final FieldType ft = sf.getType(); @@ -203,6 +214,8 @@ class FacetRangeProcessor extends FacetProcessor { (SolrException.ErrorCode.BAD_REQUEST, "Unable to range facet on tried field of unexpected type:" + freq.field); } + } else if (ft instanceof CurrencyFieldType) { + calc = new CurrencyCalc(sf); } else { throw new SolrException (SolrException.ErrorCode.BAD_REQUEST, @@ -260,7 +273,7 @@ class FacetRangeProcessor extends FacetProcessor { (include.contains(FacetParams.FacetRangeInclude.EDGE) && 0 == high.compareTo(end))); - Range range = new Range(low, low, high, incLower, incUpper); + Range range = new Range(calc.buildRangeLabel(low), low, high, incLower, incUpper); rangeList.add( range ); low = high; @@ -400,14 +413,28 @@ class FacetRangeProcessor extends FacetProcessor { this.field = field; } + /** + * Used by {@link FacetFieldProcessorByHashDV} for field faceting on numeric types -- not used for range faceting + */ public Comparable bitsToValue(long bits) { return bits; } + /** + * Used by {@link FacetFieldProcessorByHashDV} for field faceting on numeric types -- not used for range faceting + */ public long bitsToSortableBits(long bits) { return bits; } + /** + * Given the low value for a bucket, generates the appropraite "label" object to use. + * By default return the low object unmodified. + */ + public Object buildRangeLabel(Comparable low) { + return low; + } + /** * Formats a value into a label used in a response * Default Impl just uses toString() @@ -605,6 +632,84 @@ class FacetRangeProcessor extends FacetProcessor { } } + private static class CurrencyCalc extends Calc { + private String defaultCurrencyCode; + private ExchangeRateProvider exchangeRateProvider; + public CurrencyCalc(final SchemaField field) { + super(field); + if(!(this.field.getType() instanceof CurrencyFieldType)) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, + "Cannot perform range faceting over non CurrencyField fields"); + } + defaultCurrencyCode = + ((CurrencyFieldType)this.field.getType()).getDefaultCurrency(); + exchangeRateProvider = + ((CurrencyFieldType)this.field.getType()).getProvider(); + } + + /** + * Throws a Server Error that this type of operation is not supported for this field + * {@inheritDoc} + */ + @Override + public Comparable bitsToValue(long bits) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, + "Currency Field " + field.getName() + " can not be used in this way"); + } + + /** + * Throws a Server Error that this type of operation is not supported for this field + * {@inheritDoc} + */ + @Override + public long bitsToSortableBits(long bits) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, + "Currency Field " + field.getName() + " can not be used in this way"); + } + + /** + * Returns the short string representation of the CurrencyValue + * @see CurrencyValue#strValue + */ + @Override + public Object buildRangeLabel(Comparable low) { + return ((CurrencyValue)low).strValue(); + } + + @Override + public String formatValue(Comparable val) { + return ((CurrencyValue)val).strValue(); + } + + @Override + protected Comparable parseStr(final String rawval) throws java.text.ParseException { + return CurrencyValue.parse(rawval, defaultCurrencyCode); + } + + @Override + protected Object parseGap(final String rawval) throws java.text.ParseException { + return parseStr(rawval); + } + + @Override + protected Comparable parseAndAddGap(Comparable value, String gap) throws java.text.ParseException{ + if (value == null) { + throw new NullPointerException("Cannot perform range faceting on null CurrencyValue"); + } + CurrencyValue val = (CurrencyValue) value; + CurrencyValue gapCurrencyValue = + CurrencyValue.parse(gap, defaultCurrencyCode); + long gapAmount = + CurrencyValue.convertAmount(this.exchangeRateProvider, + gapCurrencyValue.getCurrencyCode(), + gapCurrencyValue.getAmount(), + val.getCurrencyCode()); + return new CurrencyValue(val.getAmount() + gapAmount, + val.getCurrencyCode()); + + } + + } // this refineFacets method is patterned after FacetFieldProcessor.refineFacets and should // probably be merged when range facet becomes more like field facet in it's ability to sort and limit @@ -709,16 +814,14 @@ class FacetRangeProcessor extends FacetProcessor { (include.contains(FacetParams.FacetRangeInclude.EDGE) && 0 == high.compareTo(end))); - Range range = new Range(low, low, high, incLower, incUpper); + Range range = new Range(calc.buildRangeLabel(low), low, high, incLower, incUpper); // now refine this range SimpleOrderedMap bucket = new SimpleOrderedMap<>(); - FieldType ft = sf.getType(); - bucket.add("val", range.low); // use "low" instead of bucketVal because it will be the right type (we may have been passed back long instead of int for example) - // String internal = ft.toInternal( tobj.toString() ); // TODO - we need a better way to get from object to query... - + bucket.add("val", range.label); + Query domainQ = sf.getType().getRangeQuery(null, sf, range.low == null ? null : calc.formatValue(range.low), range.high==null ? null : calc.formatValue(range.high), range.includeLower, range.includeUpper); fillBucket(bucket, domainQ, null, skip, facetInfo); diff --git a/solr/core/src/test/org/apache/solr/schema/CurrencyFieldTypeTest.java b/solr/core/src/test/org/apache/solr/schema/CurrencyFieldTypeTest.java index c2f8f2d97e5..c50f5c5f459 100644 --- a/solr/core/src/test/org/apache/solr/schema/CurrencyFieldTypeTest.java +++ b/solr/core/src/test/org/apache/solr/schema/CurrencyFieldTypeTest.java @@ -26,8 +26,11 @@ import java.util.Set; import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; import org.apache.lucene.index.IndexableField; import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.SolrParams; import org.apache.solr.core.SolrCore; import org.apache.solr.util.RTimer; + import org.junit.Assume; import org.junit.BeforeClass; import org.junit.Ignore; @@ -458,6 +461,238 @@ public class CurrencyFieldTypeTest extends SolrTestCaseJ4 { } + @Test + public void testStringValue() throws Exception { + assertEquals("3.14,USD", new CurrencyValue(314, "USD").strValue()); + assertEquals("-3.14,GBP", new CurrencyValue(-314, "GBP").strValue()); + assertEquals("3.14,GBP", new CurrencyValue(314, "GBP").strValue()); + + CurrencyValue currencyValue = new CurrencyValue(314, "XYZ"); + try { + String string = currencyValue.strValue(); + fail("Expected SolrException"); + } catch (SolrException exception) { + } catch (Throwable throwable) { + fail("Expected SolrException"); + } + } + + @Test + public void testRangeFacet() throws Exception { + assumeTrue("This test is only applicable to the XML file based exchange rate provider " + + "because it excercies the asymetric exchange rates option it supports", + expectedProviderClass.equals(FileExchangeRateProvider.class)); + + clearIndex(); + + // NOTE: in our test conversions EUR uses an asynetric echange rate + // these are the equivilent values when converting to: USD EUR GBP + assertU(adoc("id", "" + 1, fieldName, "10.00,USD")); // 10.00,USD 25.00,EUR 5.00,GBP + assertU(adoc("id", "" + 2, fieldName, "15.00,EUR")); // 7.50,USD 15.00,EUR 7.50,GBP + assertU(adoc("id", "" + 3, fieldName, "6.00,GBP")); // 12.00,USD 12.00,EUR 6.00,GBP + assertU(adoc("id", "" + 4, fieldName, "7.00,EUR")); // 3.50,USD 7.00,EUR 3.50,GBP + assertU(adoc("id", "" + 5, fieldName, "2,GBP")); // 4.00,USD 4.00,EUR 2.00,GBP + assertU(commit()); + + for (String suffix : Arrays.asList("", ",USD")) { + assertQ("Ensure that we get correct facet counts back in USD (explicit or implicit default) (facet.range)", + req("fl", "*,score", "q", "*:*", "rows", "0", "facet", "true", + "facet.range", fieldName, + "f." + fieldName + ".facet.range.start", "4.00" + suffix, + "f." + fieldName + ".facet.range.end", "11.00" + suffix, + "f." + fieldName + ".facet.range.gap", "1.00" + suffix, + "f." + fieldName + ".facet.range.other", "all") + ,"count(//lst[@name='counts']/int)=7" + ,"//lst[@name='counts']/int[@name='4.00,USD']='1'" + ,"//lst[@name='counts']/int[@name='5.00,USD']='0'" + ,"//lst[@name='counts']/int[@name='6.00,USD']='0'" + ,"//lst[@name='counts']/int[@name='7.00,USD']='1'" + ,"//lst[@name='counts']/int[@name='8.00,USD']='0'" + ,"//lst[@name='counts']/int[@name='9.00,USD']='0'" + ,"//lst[@name='counts']/int[@name='10.00,USD']='1'" + ,"//int[@name='after']='1'" + ,"//int[@name='before']='1'" + ,"//int[@name='between']='3'" + ); + assertQ("Ensure that we get correct facet counts back in USD (explicit or implicit default) (json.facet)", + req("fl", "*,score", "q", "*:*", "rows", "0", "json.facet", + "{ xxx : { type:range, field:" + fieldName + ", " + + " start:'4.00"+suffix+"', gap:'1.00"+suffix+"', end:'11.00"+suffix+"', other:all } }") + ,"count(//lst[@name='xxx']/arr[@name='buckets']/lst)=7" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='4.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='5.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='6.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='7.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='8.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='9.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='10.00,USD']]" + ,"//lst[@name='xxx']/lst[@name='before' ]/int[@name='count'][.='1']" + ,"//lst[@name='xxx']/lst[@name='after' ]/int[@name='count'][.='1']" + ,"//lst[@name='xxx']/lst[@name='between']/int[@name='count'][.='3']" + ); + } + + assertQ("Zero value as start range point + mincount (facet.range)", + req("fl", "*,score", "q", "*:*", "rows", "0", "facet", "true", "facet.mincount", "1", + "facet.range", fieldName, + "f." + fieldName + ".facet.range.start", "0,USD", + "f." + fieldName + ".facet.range.end", "11.00,USD", + "f." + fieldName + ".facet.range.gap", "1.00,USD", + "f." + fieldName + ".facet.range.other", "all") + ,"count(//lst[@name='counts']/int)=4" + ,"//lst[@name='counts']/int[@name='3.00,USD']='1'" + ,"//lst[@name='counts']/int[@name='4.00,USD']='1'" + ,"//lst[@name='counts']/int[@name='7.00,USD']='1'" + ,"//lst[@name='counts']/int[@name='10.00,USD']='1'" + ,"//int[@name='before']='0'" + ,"//int[@name='after']='1'" + ,"//int[@name='between']='4'" + ); + assertQ("Zero value as start range point + mincount (json.facet)", + req("fl", "*,score", "q", "*:*", "rows", "0", "json.facet", + "{ xxx : { type:range, mincount:1, field:" + fieldName + + ", start:'0.00,USD', gap:'1.00,USD', end:'11.00,USD', other:all } }") + ,"count(//lst[@name='xxx']/arr[@name='buckets']/lst)=4" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='3.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='4.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='7.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='10.00,USD']]" + ,"//lst[@name='xxx']/lst[@name='before' ]/int[@name='count'][.='0']" + ,"//lst[@name='xxx']/lst[@name='after' ]/int[@name='count'][.='1']" + ,"//lst[@name='xxx']/lst[@name='between']/int[@name='count'][.='4']" + ); + + // NOTE: because of asymetric EUR exchange rate, these buckets are diff then the similar looking USD based request above + // This request converts the values in each doc into EUR to decide what range buck it's in. + assertQ("Ensure that we get correct facet counts back in EUR (facet.range)", + req("fl", "*,score", "q", "*:*", "rows", "0", "facet", "true", + "facet.range", fieldName, + "f." + fieldName + ".facet.range.start", "8.00,EUR", + "f." + fieldName + ".facet.range.end", "22.00,EUR", + "f." + fieldName + ".facet.range.gap", "2.00,EUR", + "f." + fieldName + ".facet.range.other", "all" + ) + , "count(//lst[@name='counts']/int)=7" + , "//lst[@name='counts']/int[@name='8.00,EUR']='0'" + , "//lst[@name='counts']/int[@name='10.00,EUR']='0'" + , "//lst[@name='counts']/int[@name='12.00,EUR']='1'" + , "//lst[@name='counts']/int[@name='14.00,EUR']='1'" + , "//lst[@name='counts']/int[@name='16.00,EUR']='0'" + , "//lst[@name='counts']/int[@name='18.00,EUR']='0'" + , "//lst[@name='counts']/int[@name='20.00,EUR']='0'" + , "//int[@name='before']='2'" + , "//int[@name='after']='1'" + , "//int[@name='between']='2'" + ); + assertQ("Ensure that we get correct facet counts back in EUR (json.facet)", + req("fl", "*,score", "q", "*:*", "rows", "0", "json.facet", + "{ xxx : { type:range, field:" + fieldName + ", start:'8.00,EUR', gap:'2.00,EUR', end:'22.00,EUR', other:all } }") + ,"count(//lst[@name='xxx']/arr[@name='buckets']/lst)=7" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='8.00,EUR']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='10.00,EUR']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='12.00,EUR']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='14.00,EUR']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='16.00,EUR']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='18.00,EUR']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='20.00,EUR']]" + ,"//lst[@name='xxx']/lst[@name='before' ]/int[@name='count'][.='2']" + ,"//lst[@name='xxx']/lst[@name='after' ]/int[@name='count'][.='1']" + ,"//lst[@name='xxx']/lst[@name='between']/int[@name='count'][.='2']" + ); + + + // GBP has a symetric echange rate with USD, so these counts are *similar* to the USD based request above... + // but the asymetric EUR/USD rate means that when computing counts realtive to GBP the EUR based docs wind up in + // diff buckets + assertQ("Ensure that we get correct facet counts back in GBP (facet.range)", + req("fl", "*,score", "q", "*:*", "rows", "0", "facet", "true", + "facet.range", fieldName, + "f." + fieldName + ".facet.range.start", "2.00,GBP", + "f." + fieldName + ".facet.range.end", "5.50,GBP", + "f." + fieldName + ".facet.range.gap", "0.50,GBP", + "f." + fieldName + ".facet.range.other", "all" + ) + , "count(//lst[@name='counts']/int)=7" + , "//lst[@name='counts']/int[@name='2.00,GBP']='1'" + , "//lst[@name='counts']/int[@name='2.50,GBP']='0'" + , "//lst[@name='counts']/int[@name='3.00,GBP']='0'" + , "//lst[@name='counts']/int[@name='3.50,GBP']='1'" + , "//lst[@name='counts']/int[@name='4.00,GBP']='0'" + , "//lst[@name='counts']/int[@name='4.50,GBP']='0'" + , "//lst[@name='counts']/int[@name='5.00,GBP']='1'" + , "//int[@name='before']='0'" + , "//int[@name='after']='2'" + , "//int[@name='between']='3'" + ); + assertQ("Ensure that we get correct facet counts back in GBP (json.facet)", + req("fl", "*,score", "q", "*:*", "rows", "0", "json.facet", + "{ xxx : { type:range, field:" + fieldName + ", start:'2.00,GBP', gap:'0.50,GBP', end:'5.50,GBP', other:all } }") + ,"count(//lst[@name='xxx']/arr[@name='buckets']/lst)=7" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='2.00,GBP']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='2.50,GBP']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='3.00,GBP']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='3.50,GBP']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='4.00,GBP']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='4.50,GBP']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='5.00,GBP']]" + ,"//lst[@name='xxx']/lst[@name='before' ]/int[@name='count'][.='0']" + ,"//lst[@name='xxx']/lst[@name='after' ]/int[@name='count'][.='2']" + ,"//lst[@name='xxx']/lst[@name='between']/int[@name='count'][.='3']" + ); + + assertQ("Ensure that we can set a gap in a currency other than the start and end currencies (facet.range)", + req("fl", "*,score", "q", "*:*", "rows", "0", "facet", "true", + "facet.range", fieldName, + "f." + fieldName + ".facet.range.start", "4.00,USD", + "f." + fieldName + ".facet.range.end", "11.00,USD", + "f." + fieldName + ".facet.range.gap", "0.50,GBP", + "f." + fieldName + ".facet.range.other", "all" + ) + , "count(//lst[@name='counts']/int)=7" + , "//lst[@name='counts']/int[@name='4.00,USD']='1'" + , "//lst[@name='counts']/int[@name='5.00,USD']='0'" + , "//lst[@name='counts']/int[@name='6.00,USD']='0'" + , "//lst[@name='counts']/int[@name='7.00,USD']='1'" + , "//lst[@name='counts']/int[@name='8.00,USD']='0'" + , "//lst[@name='counts']/int[@name='9.00,USD']='0'" + , "//lst[@name='counts']/int[@name='10.00,USD']='1'" + , "//int[@name='before']='1'" + , "//int[@name='after']='1'" + , "//int[@name='between']='3'" + ); + assertQ("Ensure that we can set a gap in a currency other than the start and end currencies (json.facet)", + req("fl", "*,score", "q", "*:*", "rows", "0", "json.facet", + "{ xxx : { type:range, field:" + fieldName + ", start:'4.00,USD', gap:'0.50,GBP', end:'11.00,USD', other:all } }") + ,"count(//lst[@name='xxx']/arr[@name='buckets']/lst)=7" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='4.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='5.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='6.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='7.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='8.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='0']][str[@name='val'][.='9.00,USD']]" + ,"//lst[@name='xxx']/arr[@name='buckets']/lst[int[@name='count'][.='1']][str[@name='val'][.='10.00,USD']]" + + ,"//lst[@name='xxx']/lst[@name='before' ]/int[@name='count'][.='1']" + ,"//lst[@name='xxx']/lst[@name='after' ]/int[@name='count'][.='1']" + ,"//lst[@name='xxx']/lst[@name='between']/int[@name='count'][.='3']" + ); + + for (SolrParams facet : Arrays.asList(params("facet", "true", + "facet.range", fieldName, + "f." + fieldName + ".facet.range.start", "4.00,USD", + "f." + fieldName + ".facet.range.end", "11.00,EUR", + "f." + fieldName + ".facet.range.gap", "1.00,USD", + "f." + fieldName + ".facet.range.other", "all"), + params("json.facet", + "{ xxx : { type:range, field:" + fieldName + ", start:'4.00,USD', " + + " gap:'1.00,USD', end:'11.00,EUR', other:all } }"))) { + assertQEx("Ensure that we throw an error if we try to use different start and end currencies", + "Cannot compare CurrencyValues when their currencies are not equal", + req(facet, "q", "*:*"), + SolrException.ErrorCode.BAD_REQUEST); + } + } + @Test public void testMockFieldType() throws Exception { assumeTrue("This test is only applicable to the mock exchange rate provider", diff --git a/solr/core/src/test/org/apache/solr/search/CurrencyRangeFacetCloudTest.java b/solr/core/src/test/org/apache/solr/search/CurrencyRangeFacetCloudTest.java new file mode 100644 index 00000000000..c4b9281cc46 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/CurrencyRangeFacetCloudTest.java @@ -0,0 +1,483 @@ +/* + * 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.search; + +import java.lang.invoke.MethodHandles; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.lucene.util.TestUtil; +import org.apache.solr.client.solrj.SolrQuery; +import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.client.solrj.request.UpdateRequest; +import org.apache.solr.client.solrj.response.QueryResponse; +import org.apache.solr.client.solrj.response.RangeFacet; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CoreAdminParams; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.schema.CurrencyFieldTypeTest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.junit.BeforeClass; +import org.junit.Test; + +public class CurrencyRangeFacetCloudTest extends SolrCloudTestCase { + + private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private static final String COLLECTION = MethodHandles.lookup().lookupClass().getName(); + private static final String CONF = COLLECTION + "_configSet"; + + private static String FIELD = null; // randomized + + private static final List STR_VALS = Arrays.asList("x0", "x1", "x2"); + // NOTE: in our test conversions EUR uses an asynetric echange rate + // these are the equivilent values relative to: USD EUR GBP + private static final List VALUES = Arrays.asList("10.00,USD", // 10.00,USD 25.00,EUR 5.00,GBP + "15.00,EUR", // 7.50,USD 15.00,EUR 7.50,GBP + "6.00,GBP", // 12.00,USD 12.00,EUR 6.00,GBP + "7.00,EUR", // 3.50,USD 7.00,EUR 3.50,GBP + "2,GBP"); // 4.00,USD 4.00,EUR 2.00,GBP + private static final int NUM_DOCS = STR_VALS.size() * VALUES.size(); + + @BeforeClass + public static void setupCluster() throws Exception { + CurrencyFieldTypeTest.assumeCurrencySupport("USD", "EUR", "MXN", "GBP", "JPY", "NOK"); + FIELD = usually() ? "amount_CFT" : "amount"; + + final int numShards = TestUtil.nextInt(random(),1,5); + final int numReplicas = 1; + final int maxShardsPerNode = 1; + final int nodeCount = numShards * numReplicas; + + configureCluster(nodeCount) + .addConfig(CONF, Paths.get(TEST_HOME(), "collection1", "conf")) + .configure(); + + assertEquals(0, (CollectionAdminRequest.createCollection(COLLECTION, CONF, numShards, numReplicas) + .setMaxShardsPerNode(maxShardsPerNode) + .setProperties(Collections.singletonMap(CoreAdminParams.CONFIG, "solrconfig-minimal.xml")) + .process(cluster.getSolrClient())).getStatus()); + + cluster.getSolrClient().setDefaultCollection(COLLECTION); + + for (int id = 0; id < NUM_DOCS; id++) { // we're indexing each Currency value in 3 docs, each with a diff 'x_s' field value + // use modulo to pick the values, so we don't add the docs in strict order of either VALUES of STR_VALS + // (that way if we want ot filter by id later, it's an independent variable) + final String x = STR_VALS.get(id % STR_VALS.size()); + final String val = VALUES.get(id % VALUES.size()); + assertEquals(0, (new UpdateRequest().add(sdoc("id", "" + id, + "x_s", x, + FIELD, val)) + ).process(cluster.getSolrClient()).getStatus()); + + } + assertEquals(0, cluster.getSolrClient().commit().getStatus()); + } + + public void testSimpleRangeFacetsOfSymetricRates() throws Exception { + + for (boolean use_mincount : Arrays.asList(true, false)) { + + // exchange rates relative to USD... + // + // for all of these permutations, the numDocs in each bucket that we get back should be the same + // (regardless of the any asymetric echanges ranges, or the currency used for the 'gap') because the + // start & end are always in USD. + // + // NOTE: + // - 0,1,2 are the *input* start,gap,end + // - 3,4,5 are the *normalized* start,gap,end expected in the response + for (List args : Arrays.asList(// default currency is USD + Arrays.asList("4", "1.00", "11.0", + "4.00,USD", "1.00,USD", "11.00,USD"), + // explicit USD + Arrays.asList("4,USD", "1,USD", "11,USD", + "4.00,USD", "1.00,USD", "11.00,USD"), + // Gap can be in diff currency (but start/end must currently match) + Arrays.asList("4.00,USD", "000.50,GBP", "11,USD", + "4.00,USD", ".50,GBP", "11.00,USD"), + Arrays.asList("4.00,USD", "2,EUR", "11,USD", + "4.00,USD", "2.00,EUR", "11.00,USD"))) { + + assertEquals(6, args.size()); // sanity check + + // first let's check facet.range + SolrQuery solrQuery = new SolrQuery("q", "*:*", "rows", "0", "facet", "true", "facet.range", FIELD, + "facet.mincount", (use_mincount ? "3" : "0"), + "f." + FIELD + ".facet.range.start", args.get(0), + "f." + FIELD + ".facet.range.gap", args.get(1), + "f." + FIELD + ".facet.range.end", args.get(2), + "f." + FIELD + ".facet.range.other", "all"); + QueryResponse rsp = cluster.getSolrClient().query(solrQuery); + try { + assertEquals(NUM_DOCS, rsp.getResults().getNumFound()); + + final String start = args.get(3); + final String gap = args.get(4); + final String end = args.get(5); + + final List range_facets = rsp.getFacetRanges(); + assertEquals(1, range_facets.size()); + final RangeFacet result = range_facets.get(0); + assertEquals(FIELD, result.getName()); + assertEquals(start, result.getStart()); + assertEquals(gap, result.getGap()); + assertEquals(end, result.getEnd()); + assertEquals(3, result.getBefore()); + assertEquals(3, result.getAfter()); + assertEquals(9, result.getBetween()); + + List counts = result.getCounts(); + if (use_mincount) { + assertEquals(3, counts.size()); + for (int i = 0; i < 3; i++) { + RangeFacet.Count bucket = counts.get(i); + assertEquals((4 + (i * 3)) + ".00,USD", bucket.getValue()); + assertEquals("bucket #" + i, 3, bucket.getCount()); + } + } else { + assertEquals(7, counts.size()); + for (int i = 0; i < 7; i++) { + RangeFacet.Count bucket = counts.get(i); + assertEquals((4 + i) + ".00,USD", bucket.getValue()); + assertEquals("bucket #" + i, (i == 0 || i == 3 || i == 6) ? 3 : 0, bucket.getCount()); + } + } + } catch (AssertionError|RuntimeException ae) { + throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae); + } + + // same basic logic, w/json.facet + solrQuery = new SolrQuery("q", "*:*", "rows", "0", "json.facet", + "{ foo:{ type:range, field:"+FIELD+", mincount:"+(use_mincount ? 3 : 0)+", " + + " start:'"+args.get(0)+"', gap:'"+args.get(1)+"', end:'"+args.get(2)+"', other:all}}"); + rsp = cluster.getSolrClient().query(solrQuery); + try { + assertEquals(NUM_DOCS, rsp.getResults().getNumFound()); + + final NamedList foo = ((NamedList>)rsp.getResponse().get("facets")).get("foo"); + + assertEqualsHACK("before", 3L, ((NamedList)foo.get("before")).get("count")); + assertEqualsHACK("after", 3L, ((NamedList)foo.get("after")).get("count")); + assertEqualsHACK("between", 9L, ((NamedList)foo.get("between")).get("count")); + + final List buckets = (List) foo.get("buckets"); + + if (use_mincount) { + assertEquals(3, buckets.size()); + for (int i = 0; i < 3; i++) { + NamedList bucket = buckets.get(i); + assertEquals((4 + (3 * i)) + ".00,USD", bucket.get("val")); + assertEqualsHACK("bucket #" + i, 3L, bucket.get("count")); + } + } else { + assertEquals(7, buckets.size()); + for (int i = 0; i < 7; i++) { + NamedList bucket = buckets.get(i); + assertEquals((4 + i) + ".00,USD", bucket.get("val")); + assertEqualsHACK("bucket #" + i, (i == 0 || i == 3 || i == 6) ? 3L : 0L, bucket.get("count")); + } + } + } catch (AssertionError|RuntimeException ae) { + throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae); + } + + } + } + } + + public void testFacetRangeOfAsymetricRates() throws Exception { + // facet.range: exchange rates relative to EUR... + // + // because of the asymetric echange rate, the counts for these buckets will be different + // then if we just converted the EUR values to USD + for (boolean use_mincount : Arrays.asList(true, false)) { + final SolrQuery solrQuery = new SolrQuery("q", "*:*", "rows", "0", "facet", "true", "facet.range", FIELD, + "facet.mincount", (use_mincount ? "3" : "0"), + "f." + FIELD + ".facet.range.start", "8,EUR", + "f." + FIELD + ".facet.range.gap", "2,EUR", + "f." + FIELD + ".facet.range.end", "22,EUR", + "f." + FIELD + ".facet.range.other", "all"); + final QueryResponse rsp = cluster.getSolrClient().query(solrQuery); + try { + assertEquals(NUM_DOCS, rsp.getResults().getNumFound()); + final List range_facets = rsp.getFacetRanges(); + assertEquals(1, range_facets.size()); + final RangeFacet result = range_facets.get(0); + assertEquals(FIELD, result.getName()); + assertEquals("8.00,EUR", result.getStart()); + assertEquals("2.00,EUR", result.getGap()); + assertEquals("22.00,EUR", result.getEnd()); + assertEquals(6, result.getBefore()); + assertEquals(3, result.getAfter()); + assertEquals(6, result.getBetween()); + + List counts = result.getCounts(); + if (use_mincount) { + assertEquals(2, counts.size()); + for (int i = 0; i < 2; i++) { + RangeFacet.Count bucket = counts.get(i); + assertEquals((12 + (i * 2)) + ".00,EUR", bucket.getValue()); + assertEquals("bucket #" + i, 3, bucket.getCount()); + } + } else { + assertEquals(7, counts.size()); + for (int i = 0; i < 7; i++) { + RangeFacet.Count bucket = counts.get(i); + assertEquals((8 + (i * 2)) + ".00,EUR", bucket.getValue()); + assertEquals("bucket #" + i, (i == 2 || i == 3) ? 3 : 0, bucket.getCount()); + } + } + } catch (AssertionError|RuntimeException ae) { + throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae); + } + } + } + + public void testJsonFacetRangeOfAsymetricRates() throws Exception { + // json.facet: exchange rates relative to EUR (same as testFacetRangeOfAsymetricRates) + // + // because of the asymetric echange rate, the counts for these buckets will be different + // then if we just converted the EUR values to USD + for (boolean use_mincount : Arrays.asList(true, false)) { + final SolrQuery solrQuery = new SolrQuery("q", "*:*", "rows", "0", "json.facet", + "{ foo:{ type:range, field:"+FIELD+", start:'8,EUR', " + + " mincount:"+(use_mincount ? 3 : 0)+", " + + " gap:'2,EUR', end:'22,EUR', other:all}}"); + final QueryResponse rsp = cluster.getSolrClient().query(solrQuery); + try { + assertEquals(NUM_DOCS, rsp.getResults().getNumFound()); + + final NamedList foo = ((NamedList>)rsp.getResponse().get("facets")).get("foo"); + + assertEqualsHACK("before", 6L, ((NamedList)foo.get("before")).get("count")); + assertEqualsHACK("after", 3L, ((NamedList)foo.get("after")).get("count")); + assertEqualsHACK("between", 6L, ((NamedList)foo.get("between")).get("count")); + + final List buckets = (List) foo.get("buckets"); + + if (use_mincount) { + assertEquals(2, buckets.size()); + for (int i = 0; i < 2; i++) { + NamedList bucket = buckets.get(i); + assertEquals((12 + (i * 2)) + ".00,EUR", bucket.get("val")); + assertEqualsHACK("bucket #" + i, 3L, bucket.get("count")); + } + } else { + assertEquals(7, buckets.size()); + for (int i = 0; i < 7; i++) { + NamedList bucket = buckets.get(i); + assertEquals((8 + (i * 2)) + ".00,EUR", bucket.get("val")); + assertEqualsHACK("bucket #" + i, (i == 2 || i == 3) ? 3L : 0L, bucket.get("count")); + } + } + } catch (AssertionError|RuntimeException ae) { + throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae); + } + } + } + + public void testFacetRangeCleanErrorOnMissmatchCurrency() { + final String expected = "Cannot compare CurrencyValues when their currencies are not equal"; + ignoreException(expected); + + // test to check clean error when start/end have diff currency (facet.range) + final SolrQuery solrQuery = new SolrQuery("q", "*:*", "rows", "0", "facet", "true", "facet.range", FIELD, + "f." + FIELD + ".facet.range.start", "0,EUR", + "f." + FIELD + ".facet.range.gap", "10,EUR", + "f." + FIELD + ".facet.range.end", "100,USD"); + final SolrException ex = expectThrows(SolrException.class, () -> { + final QueryResponse rsp = cluster.getSolrClient().query(solrQuery); + }); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue(ex.getMessage(), ex.getMessage().contains(expected)); + } + + public void testJsonFacetCleanErrorOnMissmatchCurrency() { + final String expected = "Cannot compare CurrencyValues when their currencies are not equal"; + ignoreException(expected); + + // test to check clean error when start/end have diff currency (json.facet) + final SolrQuery solrQuery = new SolrQuery("q", "*:*", "json.facet", + "{ x:{ type:range, field:"+FIELD+", " + + " start:'0,EUR', gap:'10,EUR', end:'100,USD' } }"); + final SolrException ex = expectThrows(SolrException.class, () -> { + final QueryResponse rsp = cluster.getSolrClient().query(solrQuery); + }); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue(ex.getMessage(), ex.getMessage().contains(expected)); + } + + @Test + public void testJsonRangeFacetWithSubFacet() throws Exception { + + // range facet, with terms facet nested under using limit=2 w/overrequest disabled + // filter out the first 5 docs (by id) which should ensure that regardless of sharding: + // - x2 being the top term for the 1st range bucket + // - x0 being the top term for the 2nd range bucket + // - the 2nd term in each bucket may vary based on shard/doc placement, but the count will always be '1' + // ...and in many cases (based on the shard/doc placement) this will require refinement to backfill the top terms + final String filter = "id_i1:["+VALUES.size()+" TO *]"; + + // the *facet* results should be the same regardless of wether we filter via fq, or using a domain filter on the top facet + for (boolean use_domain : Arrays.asList(true, false)) { + final String domain = use_domain ? "domain: { filter:'" + filter + "'}," : ""; + final SolrQuery solrQuery = new SolrQuery("q", (use_domain ? "*:*" : filter), + "rows", "0", "json.facet", + "{ bar:{ type:range, field:"+FIELD+", " + domain + + " start:'0,EUR', gap:'10,EUR', end:'20,EUR', other:all " + + " facet: { foo:{ type:terms, field:x_s, " + + " refine:true, limit:2, overrequest:0" + + " } } } }"); + final QueryResponse rsp = cluster.getSolrClient().query(solrQuery); + try { + // this top level result count sanity check that should vary based on how we are filtering our facets... + assertEquals(use_domain ? 15 : 10, rsp.getResults().getNumFound()); + + final NamedList bar = ((NamedList>)rsp.getResponse().get("facets")).get("bar"); + final List> bar_buckets = (List>) bar.get("buckets"); + final NamedList before = (NamedList) bar.get("before"); + final NamedList between = (NamedList) bar.get("between"); + final NamedList after = (NamedList) bar.get("after"); + + // sanity check our high level expectations... + assertEquals("bar num buckets", 2, bar_buckets.size()); + assertEqualsHACK("before count", 0L, before.get("count")); + assertEqualsHACK("between count", 8L, between.get("count")); + assertEqualsHACK("after count", 2L, after.get("count")); + + // drill into the various buckets... + + // before should have no subfacets since it's empty... + assertNull("before has foo???", before.get("foo")); + + // our 2 range buckets & their sub facets... + for (int i = 0; i < 2; i++) { + final NamedList bucket = bar_buckets.get(i); + assertEquals((i * 10) + ".00,EUR", bucket.get("val")); + assertEqualsHACK("bucket #" + i, 4L, bucket.get("count")); + final List> foo_buckets = ((NamedList>>)bucket.get("foo")).get("buckets"); + assertEquals("bucket #" + i + " foo num buckets", 2, foo_buckets.size()); + assertEquals("bucket #" + i + " foo top term", (0==i ? "x2" : "x0"), foo_buckets.get(0).get("val")); + assertEqualsHACK("bucket #" + i + " foo top count", 2, foo_buckets.get(0).get("count")); + // NOTE: we can't make any assertions about the 2nd val.. + // our limit + randomized sharding could result in either remaining term being picked. + // but for eiter term, the count should be exactly the same... + assertEqualsHACK("bucket #" + i + " foo 2nd count", 1, foo_buckets.get(1).get("count")); + } + + { // between... + final List> buckets = ((NamedList>>)between.get("foo")).get("buckets"); + assertEquals("between num buckets", 2, buckets.size()); + // the counts should both be 3, and the term order should break the tie... + assertEquals("between bucket top", "x0", buckets.get(0).get("val")); + assertEqualsHACK("between bucket top count", 3L, buckets.get(0).get("count")); + assertEquals("between bucket 2nd", "x2", buckets.get(1).get("val")); + assertEqualsHACK("between bucket 2nd count", 3L, buckets.get(1).get("count")); + } + + { // after... + final List> buckets = ((NamedList>>)after.get("foo")).get("buckets"); + assertEquals("after num buckets", 2, buckets.size()); + // the counts should both be 1, and the term order should break the tie... + assertEquals("after bucket top", "x1", buckets.get(0).get("val")); + assertEqualsHACK("after bucket top count", 1L, buckets.get(0).get("count")); + assertEquals("after bucket 2nd", "x2", buckets.get(1).get("val")); + assertEqualsHACK("after bucket 2nd count", 1L, buckets.get(1).get("count")); + } + + } catch (AssertionError|RuntimeException ae) { + throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae); + } + } + } + + @Test + public void testJsonRangeFacetAsSubFacet() throws Exception { + + // limit=1, overrequest=1, with refinement enabled + // filter out the first 5 docs (by id), which should ensure that 'x2' is the top bucket overall... + // ...except in some rare sharding cases where it doesn't make it into the top 2 terms. + // + // So the filter also explicitly accepts all 'x2' docs -- ensuring we have enough matches containing that term for it + // to be enough of a candidate in phase#1, but for many shard arrangements it won't be returned by all shards resulting + // in refinement being neccessary to get the x_s:x2 sub-shard ranges from shard(s) where x_s:x2 is only tied for the + // (shard local) top term count and would lose the (index order) tie breaker with x_s:x0 or x_s:x1 + final String filter = "id_i1:["+VALUES.size()+" TO *] OR x_s:x2"; + + // the *facet* results should be the same regardless of wether we filter via fq, or using a domain filter on the top facet + for (boolean use_domain : Arrays.asList(true, false)) { + final String domain = use_domain ? "domain: { filter:'" + filter + "'}," : ""; + final SolrQuery solrQuery = new SolrQuery("q", (use_domain ? "*:*" : filter), + "rows", "0", "json.facet", + "{ foo:{ type:terms, field:x_s, refine:true, limit:1, overrequest:1, " + domain + + " facet: { bar:{ type:range, field:"+FIELD+", other:all, " + + " start:'8,EUR', gap:'2,EUR', end:'22,EUR' }} } }"); + final QueryResponse rsp = cluster.getSolrClient().query(solrQuery); + try { + // this top level result count sanity check that should vary based on how we are filtering our facets... + assertEquals(use_domain ? 15 : 11, rsp.getResults().getNumFound()); + + final NamedList foo = ((NamedList>)rsp.getResponse().get("facets")).get("foo"); + + // sanity check... + // because of the facet limit, foo should only have 1 bucket + // because of the fq, the val should be "x2" and the count=5 + final List> foo_buckets = (List>) foo.get("buckets"); + assertEquals(1, foo_buckets.size()); + assertEquals("x2", foo_buckets.get(0).get("val")); + assertEqualsHACK("foo bucket count", 5L, foo_buckets.get(0).get("count")); + + final NamedList bar = (NamedList)foo_buckets.get(0).get("bar"); + + // these are the 'x2' specific counts, based on our fq... + + assertEqualsHACK("before", 2L, ((NamedList)bar.get("before")).get("count")); + assertEqualsHACK("after", 1L, ((NamedList)bar.get("after")).get("count")); + assertEqualsHACK("between", 2L, ((NamedList)bar.get("between")).get("count")); + + final List buckets = (List) bar.get("buckets"); + assertEquals(7, buckets.size()); + for (int i = 0; i < 7; i++) { + NamedList bucket = buckets.get(i); + assertEquals((8 + (i * 2)) + ".00,EUR", bucket.get("val")); + // 12,EUR & 15,EUR are the 2 values that align with x2 docs + assertEqualsHACK("bucket #" + i, (i == 2 || i == 3) ? 1L : 0L, bucket.get("count")); + } + } catch (AssertionError|RuntimeException ae) { + throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae); + } + } + } + + /** + * HACK to work around SOLR-11775. + * Asserts that the 'actual' argument is a (non-null) Number, then compares it's 'longValue' to the 'expected' argument + */ + private static void assertEqualsHACK(String msg, long expected, Object actual) { + assertNotNull(msg, actual); + assertTrue(msg + " ... NOT A NUMBER: " + actual.getClass(), Number.class.isInstance(actual)); + assertEquals(msg, expected, ((Number)actual).longValue()); + } + +} diff --git a/solr/server/solr/configsets/sample_techproducts_configs/conf/velocity/VM_global_library.vm b/solr/server/solr/configsets/sample_techproducts_configs/conf/velocity/VM_global_library.vm index 76516b7935b..ef2157c76e5 100644 --- a/solr/server/solr/configsets/sample_techproducts_configs/conf/velocity/VM_global_library.vm +++ b/solr/server/solr/configsets/sample_techproducts_configs/conf/velocity/VM_global_library.vm @@ -173,7 +173,11 @@ $val## #macro(range_get_to_value $inval, $gapval) #if(${gapval.class.name} == "java.lang.String") -$inval$gapval## +#if($gapval.startsWith("+")) +$inval$gapval## Typically date gaps start with + +#else +$inval+$gapval## If the gap does not start with a "+", we add it, such as for currency value +#end #elseif(${gapval.class.name} == "java.lang.Float" || ${inval.class.name} == "java.lang.Float") $math.toDouble($math.add($inval,$gapval))## #else diff --git a/solr/solr-ref-guide/src/working-with-currencies-and-exchange-rates.adoc b/solr/solr-ref-guide/src/working-with-currencies-and-exchange-rates.adoc index 5b1e23a788c..4ee57111e64 100644 --- a/solr/solr-ref-guide/src/working-with-currencies-and-exchange-rates.adoc +++ b/solr/solr-ref-guide/src/working-with-currencies-and-exchange-rates.adoc @@ -24,6 +24,7 @@ The `currency` FieldType provides support for monetary values to Solr/Lucene wit * Sorting * Currency parsing by either currency code or symbol * Symmetric & asymmetric exchange rates (asymmetric exchange rates are useful if there are fees associated with exchanging the currency) +* Range faceting (using either `facet.range` or `type:range` in `json.facet`) as long as the `start` and `end` values are specified in the same Currency. == Configuring Currencies diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java b/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java index 4e7800505b1..3124a0b8cf0 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java @@ -384,7 +384,7 @@ public class QueryResponse extends SolrResponseBase Number between = (Number) values.get("between"); rangeFacet = new RangeFacet.Numeric(facet.getKey(), start, end, gap, before, after, between); - } else { + } else if (rawGap instanceof String && values.get("start") instanceof Date) { String gap = (String) rawGap; Date start = (Date) values.get("start"); Date end = (Date) values.get("end"); @@ -394,8 +394,18 @@ public class QueryResponse extends SolrResponseBase Number between = (Number) values.get("between"); rangeFacet = new RangeFacet.Date(facet.getKey(), start, end, gap, before, after, between); + } else { + String gap = (String) rawGap; + String start = (String) values.get("start"); + String end = (String) values.get("end"); + + Number before = (Number) values.get("before"); + Number after = (Number) values.get("after"); + Number between = (Number) values.get("between"); + + rangeFacet = new RangeFacet.Currency(facet.getKey(), start, end, gap, before, after, between); } - + NamedList counts = (NamedList) values.get("counts"); for (Map.Entry entry : counts) { rangeFacet.addCount(entry.getKey(), entry.getValue()); diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/response/RangeFacet.java b/solr/solrj/src/java/org/apache/solr/client/solrj/response/RangeFacet.java index 6829c17b61b..b970ef545af 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/response/RangeFacet.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/response/RangeFacet.java @@ -97,6 +97,12 @@ public abstract class RangeFacet { } + public static class Currency extends RangeFacet { + public Currency(String name, String start, String end, String gap, Number before, Number after, Number between) { + super(name, start, end, gap, before, after, between); + } + } + public static class Count { private final String value;