001 package hirondelle.fish.main.search; 002 003 import java.util.regex.Pattern; 004 import hirondelle.web4j.model.Check; 005 import hirondelle.web4j.model.ModelCtorException; 006 import hirondelle.web4j.util.Util; 007 import hirondelle.web4j.model.ModelUtil; 008 import static hirondelle.web4j.util.Consts.FAILS; 009 import hirondelle.web4j.security.SafeText; 010 import hirondelle.web4j.model.Decimal; 011 import static hirondelle.web4j.model.Decimal.ZERO;; 012 013 /** 014 Model Object for a search on a restaurant. 015 016 <P>This Model Object is a bit unusual since its data is never persisted, 017 and no such objects are returned by a DAO. It exists for these reasons : 018 <ul> 019 <li>perform validation on user input 020 <li>gather together all criteria into one place 021 </ul> 022 023 <P><em>Design Note</em><br> 024 This class is different from the usual Model Object. 025 Its <tt>getXXX</tt> methods are package-private, since it is used only by {@link RestoSearchAction}, 026 and not in a JSP. 027 */ 028 public final class RestoSearchCriteria { 029 030 enum SortColumn {Name, Price}; 031 032 /** 033 Constructor. 034 035 <P>At least one criterion must be entered, on either the name, or the price range. 036 When a price is specified, both minimum and maximum must be included. 037 Some restaurants do not have an associated price. Such records will NOT 038 be retrieved. 039 040 @param aStartsWith (optional) first few letters of the restaurant name. Cannot be longer 041 than {@link #MAX_LENGTH} characters. Cannot contain the {@link #WILDCARD} character. 042 @param aMinPrice (optional) minumum price for the cost of lunch, in the range <tt>0.00..100.00</tt>. 043 Must be less than or equal to <tt>aMaxPrice</tt>, 2 decimals. 044 @param aMaxPrice (optional) minumum price for the cost of lunch, in the range <tt>0.00..100.00</tt>, 2 decimals. 045 @param aOrderBy (optional) is converted internally into an element of the {@link SortColumn} enumeration. 046 @param aIsReverseOrder (optional) toggles the sort order, <tt>ASC</tt> versus <tt>DESC</tt>. 047 */ 048 public RestoSearchCriteria( 049 SafeText aStartsWith, Decimal aMinPrice, Decimal aMaxPrice, 050 SafeText aOrderBy, Boolean aIsReverseOrder 051 ) throws ModelCtorException { 052 fStartsWith = aStartsWith; 053 fMinPrice = aMinPrice; 054 fMaxPrice = aMaxPrice; 055 fOrderBy = aOrderBy == null ? null : SortColumn.valueOf(aOrderBy.getRawString()); 056 fIsReverseOrder = Util.nullMeansFalse(aIsReverseOrder); 057 validateState(); 058 } 059 060 /** Value {@value} - SQL wildcard character, not permitted as part of input user name. */ 061 static final String WILDCARD = "%"; 062 /** Value {@value} - maximum length of the input restaurant name. */ 063 static final int MAX_LENGTH = 20; 064 065 /** 066 Return user input for <tt>Starts With</tt>, concatenated with {@link #WILDCARD}. 067 068 <P>If the user has not entered any <tt>Starts With</tt> criterion, then return <tt>null</tt>. 069 */ 070 SafeText getStartsWith() { 071 return Util.textHasContent(fStartsWith) ? SafeText.from(fStartsWith + WILDCARD) : null; 072 } 073 Decimal getMinPrice() { return fMinPrice; } 074 Decimal getMaxPrice() { return fMaxPrice; } 075 SortColumn getOrderBy() { return fOrderBy; } 076 Boolean isReverseOrder() { return fIsReverseOrder; } 077 078 @Override public String toString() { 079 return ModelUtil.toStringFor(this); 080 } 081 082 @Override public boolean equals(Object aThat){ 083 Boolean result = ModelUtil.quickEquals(this, aThat); 084 if ( result == null ){ 085 RestoSearchCriteria that = (RestoSearchCriteria) aThat; 086 result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); 087 } 088 return result; 089 } 090 091 @Override public int hashCode(){ 092 return ModelUtil.hashCodeFor(getSignificantFields()); 093 } 094 095 // PRIVATE 096 private final SafeText fStartsWith; 097 private final Decimal fMinPrice; 098 private final Decimal fMaxPrice; 099 private final SortColumn fOrderBy; 100 private final Boolean fIsReverseOrder; 101 102 private static final Decimal HUNDRED = Decimal.from("100.00"); 103 private static final Pattern NO_WILDCARD = Pattern.compile(".*[^" + WILDCARD + "]$"); 104 105 private void validateState() throws ModelCtorException { 106 ModelCtorException ex = new ModelCtorException(); 107 108 if( FAILS == Check.optional(fStartsWith, Check.max(MAX_LENGTH)) ) { 109 ex.add("Please enter a shorter Restaurant Name."); 110 } 111 if( FAILS == Check.optional(fStartsWith, Check.pattern(NO_WILDCARD)) ){ 112 ex.add("Restaurant name (_1_) cannot have this character at the end : _2_", fStartsWith, Util.quote(WILDCARD)); 113 } 114 if( FAILS == Check.optional(fMinPrice, Check.range(ZERO,HUNDRED), Check.numDecimalsAlways(2)) ){ 115 ex.add("Minimum Price (_1_) must be in the range 0.00 to 100.00, 2 decimals", fMinPrice.toString()); 116 } 117 if( FAILS == Check.optional(fMaxPrice, Check.range(ZERO,HUNDRED), Check.numDecimalsAlways(2)) ){ 118 ex.add("Maximum Price (_1_) must be in the range 0.00 to 100.00, 2 decimals", fMaxPrice.toString() ); 119 } 120 if ( fMaxPrice != null || fMinPrice != null ){ 121 if( fMaxPrice == null || fMinPrice == null ){ 122 ex.add("When specifying price, please specify both minimum and maximum."); 123 } 124 } 125 if( fMaxPrice != null && fMinPrice != null ){ 126 if ( fMinPrice.gt(fMaxPrice) ){ 127 ex.add("Minimum price cannot be greater than maximum price."); 128 } 129 } 130 if ( ! Util.textHasContent(fStartsWith) && fMinPrice == null && fMaxPrice == null ) { 131 ex.add("Please enter criteria on name and/or price."); 132 } 133 134 if ( ex.isNotEmpty() ) throw ex; 135 } 136 137 private Object[] getSignificantFields(){ 138 return new Object[] {fStartsWith, fMinPrice, fMaxPrice, fOrderBy, fIsReverseOrder}; 139 } 140 }