SOLR-3218: Added range faceting support for CurrencyFieldType

This commit is contained in:
Chris Hostetter 2018-01-14 16:30:24 -07:00
parent 972df6c69d
commit 6dcbb2d412
11 changed files with 1162 additions and 170 deletions

View File

@ -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
----------------------

View File

@ -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<CurrencyValue> {
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
*/

View File

@ -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<String, String> 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.
* <p/>
* Currency values are expected to be in the format &lt;amount&gt;,&lt;currency code&gt;,
* for example, "500,USD" would represent 5 U.S. Dollars.
* <p/>
* 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;
}
}
}

View File

@ -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<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.
* <p>
* Currency values are expected to be in the format &lt;amount&gt;,&lt;currency code&gt;,
* for example, "500,USD" would represent 5 U.S. Dollars.
* </p>
* <p>
* If no currency code is specified, the default is assumed.
* </p>
* @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 &amp; 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 &amp; 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 &amp; 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();
}
}

View File

@ -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<FacetRange> {
}
}
/**
* Returns a {@link Calc} instance to use for <em>term</em> faceting over a numeric field.
* This metod is unused for <code>range</code> 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 <code>Calc</code> 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<FacetRange> {
(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<FacetRange> {
(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<FacetRange> {
this.field = field;
}
/**
* Used by {@link FacetFieldProcessorByHashDV} for field faceting on numeric types -- not used for <code>range</code> faceting
*/
public Comparable bitsToValue(long bits) {
return bits;
}
/**
* Used by {@link FacetFieldProcessorByHashDV} for field faceting on numeric types -- not used for <code>range</code> 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<FacetRange> {
}
}
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<FacetRange> {
(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<Object> 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);

View File

@ -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",

View File

@ -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<String> 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<String> 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<String> 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<RangeFacet> 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<RangeFacet.Count> 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<Object> foo = ((NamedList<NamedList<Object>>)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<NamedList> buckets = (List<NamedList>) 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<RangeFacet> 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<RangeFacet.Count> 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<Object> foo = ((NamedList<NamedList<Object>>)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<NamedList> buckets = (List<NamedList>) 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<Object> bar = ((NamedList<NamedList<Object>>)rsp.getResponse().get("facets")).get("bar");
final List<NamedList<Object>> bar_buckets = (List<NamedList<Object>>) bar.get("buckets");
final NamedList<Object> before = (NamedList<Object>) bar.get("before");
final NamedList<Object> between = (NamedList<Object>) bar.get("between");
final NamedList<Object> after = (NamedList<Object>) 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<Object> bucket = bar_buckets.get(i);
assertEquals((i * 10) + ".00,EUR", bucket.get("val"));
assertEqualsHACK("bucket #" + i, 4L, bucket.get("count"));
final List<NamedList<Object>> foo_buckets = ((NamedList<List<NamedList<Object>>>)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<NamedList<Object>> buckets = ((NamedList<List<NamedList<Object>>>)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<NamedList<Object>> buckets = ((NamedList<List<NamedList<Object>>>)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<Object> foo = ((NamedList<NamedList<Object>>)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<NamedList<Object>> foo_buckets = (List<NamedList<Object>>) 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<Object> bar = (NamedList<Object>)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<NamedList> buckets = (List<NamedList>) 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());
}
}

View File

@ -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

View File

@ -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

View File

@ -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<Integer> counts = (NamedList<Integer>) values.get("counts");
for (Map.Entry<String, Integer> entry : counts) {
rangeFacet.addCount(entry.getKey(), entry.getValue());

View File

@ -97,6 +97,12 @@ public abstract class RangeFacet<B, G> {
}
public static class Currency extends RangeFacet<String, String> {
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;