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 }