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    }