View Javadoc
1   /**
2    * Copyright 2010 The Kuali Foundation Licensed under the
3    * Educational Community License, Version 2.0 (the "License"); you may
4    * not use this file except in compliance with the License. You may
5    * obtain a copy of the License at
6    *
7    * http://www.osedu.org/licenses/ECL-2.0
8    *
9    * Unless required by applicable law or agreed to in writing,
10   * software distributed under the License is distributed on an "AS IS"
11   * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
12   * or implied. See the License for the specific language governing
13   * permissions and limitations under the License.
14   */
15  
16  package org.kuali.student.r2.common.dao.impl;
17  
18  import org.kuali.student.r2.core.search.dto.QueryParamInfo;
19  import org.kuali.student.r2.core.search.dto.ResultColumnInfo;
20  import org.kuali.student.r2.core.search.dto.SearchTypeInfo;
21  import org.kuali.student.r2.core.search.dto.*;
22  import org.slf4j.Logger;
23  import org.slf4j.LoggerFactory;
24  
25  import javax.persistence.EntityManager;
26  import javax.persistence.Query;
27  import java.math.BigDecimal;
28  import java.text.DateFormat;
29  import java.text.ParseException;
30  import java.text.SimpleDateFormat;
31  import java.util.*;
32  
33  public class SearchableCrudDaoImpl {
34  	private static final Logger LOG = LoggerFactory.getLogger(SearchableCrudDaoImpl.class);
35  
36      protected EntityManager em;
37  
38      public SearchableCrudDaoImpl(){
39          super();
40      }
41  
42      public EntityManager getEm() {
43          return em;
44      }
45  
46      public void setEm(EntityManager em) {
47          this.em = em;
48      }
49  	
50  	private static ThreadLocal<DateFormat> df = new ThreadLocal<DateFormat>() {
51  		protected DateFormat initialValue() {
52  			return new SimpleDateFormat("EEE MMM dd hh:mm:ss zzz yyyy");
53  		}
54  	};
55  
56  	public SearchResultInfo search(SearchRequestInfo searchRequest,	Map<String, String> queryMap, SearchTypeInfo searchTypeInfo) {
57  		String searchKey = searchRequest.getSearchKey();
58  		
59  		boolean isNative = false;
60  		
61  		//retrieve the SELECT statement from search type definition
62  		String queryString = queryMap.get(searchKey);
63  		StringBuilder optionalQueryString = new StringBuilder();
64  		if(null == queryString){
65  			LOG.error("No SQL query was found for searchKey: {}", searchKey);
66  		}
67  		
68  		if(queryString.toUpperCase().startsWith("NATIVE:")){
69  			queryString = queryString.substring("NATIVE:".length());
70  			isNative = true;
71  		}
72  		
73  		//add in optional
74  		List<SearchParamInfo> searchParamsTemp = new ArrayList<SearchParamInfo>(searchRequest.getParams());
75  		// internalQueryParms is used only internally to know which parameters have to be set in the query
76  		List<SearchParamInfo> internalQueryParms = new ArrayList<SearchParamInfo>(searchRequest.getParams());
77  		for(SearchParamInfo searchParam : searchParamsTemp){
78  			for(QueryParamInfo queryParam:searchTypeInfo.getSearchCriteriaTypeInfo().getQueryParams()){
79  				// check to see if optional param has any values set.
80  				if(queryParam.isOptional()&&queryParam.getKey().equals(searchParam.getKey())
81                          &&searchParam.getValues()!=null&&searchParam.getValues().size()>0&&searchParam.getValues().get(0)!=null){
82  					if (optionalQueryString.length() != 0) {
83  						optionalQueryString.append(" AND ");
84  					}
85  					
86  					//if optional query parameter has only a column name then create proper search expression
87  					String condition = queryMap.get(searchParam.getKey());
88  					if(condition==null){
89  						throw new RuntimeException("Optional Param "+searchParam.getKey()+" must have a queryMap definition");
90  					}
91  					if (condition.trim().startsWith("!!")) {
92  					    String substitutionType = condition.trim().substring("!!".length());
93  					    // to detect queryMap value in the form of !!____ ___
94  					    if (condition.contains(" ")) {
95  					        substitutionType = condition.substring("!!".length(), condition.indexOf(" "));
96  					    }
97  					    
98  					    if (substitutionType != null && substitutionType.equals("NUMBER_RANGE")) {
99  					        String realCondition = condition.substring("!!".length() + substitutionType.length()).trim();
100 					        String queryValue = (String)searchParam.getValues().get(0);
101 					        // if the query value is of the form n1 - n2
102 					        if (queryValue != null && queryValue.trim().contains("-")) {
103 					            StringTokenizer strTokenizer = new StringTokenizer(queryValue.trim(),"-");
104 					            if (strTokenizer.hasMoreElements()) {
105 					                String strNum1 = strTokenizer.nextToken().trim();
106 					                String strNum2 = strTokenizer.nextToken().trim();
107 					                optionalQueryString
108                                             .append(realCondition)
109                                             .append(" BETWEEN '").append(strNum1).append("' AND '").append(strNum2).append("'");
110 					                internalQueryParms.remove(searchParam);
111 					            }
112 					        } else {
113 					            // the value is just one number
114 					            optionalQueryString.append(realCondition).append(" = '").append(queryValue).append( "'");
115 					            internalQueryParms.remove(searchParam);
116 					        }
117 					    }
118 					} else if (condition.trim().contains(":")) {
119 					    //this parameter is not entered by end user but rather it is set with a default context value	
120 					    String dataType = queryParam.getFieldDescriptor().getDataType();
121 					    if ((dataType != null) && "boolean".equals(dataType)) {
122 					        optionalQueryString.append(queryMap.get(searchParam.getKey()).replace(":" + searchParam.getKey().replace(".", "_"), searchParam.getValues().get(0)));
123 					        internalQueryParms.remove(searchParam);
124 					    } else {					    
125 					        optionalQueryString.append(queryMap.get(searchParam.getKey()));
126 					    }						
127 					} else {
128 						//comparison should be case insensitive and should include wild card such that we match beginning of a text 
129 						//and each word within text
130 						//FIXME SQL injection can occur here - or NOT if we need to assemble SQL to cover various ways one can compare criteria to a text
131 						optionalQueryString
132                                 .append("(LOWER(").append(queryMap.get(searchParam.getKey())).append(") LIKE LOWER(:")
133                                 .append(searchParam.getKey().replace(".", "_")).append(") || '%' OR ")
134                                 .append("LOWER(").append(queryMap.get(searchParam.getKey())).append(") LIKE '% ' || LOWER(:")
135                                 .append(searchParam.getKey().replace(".", "_")).append(") || '%')");
136 					}
137 				}
138 			}
139 		}
140 		
141 		//Add in the where clause or And clause if needed for the optional criteria
142 		if (optionalQueryString.length() != 0) {
143 			if(!queryString.toUpperCase().contains(" WHERE ")){
144 				queryString += " WHERE ";
145 			}
146 			else {
147 				queryString += " AND ";
148 			}
149 		}
150 		
151 		//Do ordering
152 		String orderByClause = "";		
153 		if(!queryString.toUpperCase().contains("ORDER BY")&&searchRequest.getSortColumn()!=null){
154 			//make sure the sort column is a real result column
155 			int i = 0;
156 			
157 			//Get an array of the jpql results
158             String lowercaseQueryString = queryString.toLowerCase();
159 			int selectIndex = lowercaseQueryString.indexOf("select")+"select".length();
160             int distinctIndex = lowercaseQueryString.indexOf("distinct")+"distinct".length();//Filter out the "distinct"
161 			int fromIndex = lowercaseQueryString.indexOf(" from ");
162             if(selectIndex < distinctIndex && distinctIndex < fromIndex){
163                 selectIndex = distinctIndex;
164             }
165 			
166 			if (selectIndex >= 0 && fromIndex > selectIndex){
167 				String[] jpqlResultColumns = queryString.substring(selectIndex, fromIndex).replaceAll("\\s", "").split(",");
168 				for(ResultColumnInfo results : searchTypeInfo.getSearchResultTypeInfo().getResultColumns()){
169 					if(results.getKey().equals(searchRequest.getSortColumn())){
170                         if(results.getDataType()!=null && "string".equals(results.getDataType().toLowerCase())){
171                         	orderByClause = " ORDER BY LOWER(" + jpqlResultColumns[i] + ") ";
172                         }else{
173                         	//Don't sort dates or numbers in alphabetic order or weirdness happens
174                         	orderByClause = " ORDER BY " + jpqlResultColumns[i] + " ";
175                         }
176 						if(searchRequest.getSortDirection()!=null && searchRequest.getSortDirection()==SortDirection.DESC){
177 							orderByClause += "DESC ";
178 						}else{
179 							orderByClause += "ASC ";
180 						}
181 					}
182 					i++;
183 				}
184 			}			
185 		}
186 		
187 		//Create the query
188 		String finalQueryString = queryString + optionalQueryString + orderByClause;
189 		
190 		//remove special characters and extra spaces
191 		//finalQueryString = finalQueryString.replaceAll("[\n\r\t]", " ");
192 		//finalQueryString = finalQueryString.replaceAll("\\s+", " ");
193 		
194 		Query query;
195 		if(isNative){
196 			LOG.info("Native Query: {}", finalQueryString);
197 			query = em.createNativeQuery(finalQueryString);
198 		}else{
199 			LOG.info("JPQL Query: {}", finalQueryString);
200 			query = em.createQuery(finalQueryString);
201 		}
202 		
203 		//Set the pagination information (eg. only return 25 rows starting at row 100)
204 		if(searchRequest.getStartAt()!=null){
205 			query.setFirstResult(searchRequest.getStartAt().intValue());
206 		}
207 		if(searchRequest.getMaxResults()!=null){
208 			query.setMaxResults(searchRequest.getMaxResults().intValue());
209 		}
210 		
211 		//replace all the "." notation with "_" since the "."s in the ids of the queries will cause problems with the jpql  
212 		for (SearchParamInfo searchParam : internalQueryParms) {
213 			// check to see if optional param has any values set.
214 			if (searchParam.getValues() != null &&
215                     searchParam.getValues().size() > 0 &&
216                     searchParam.getValues().get(0) != null) {
217 			    List<QueryParamInfo> queryParams = searchTypeInfo.getSearchCriteriaTypeInfo().getQueryParams();
218 			    String paramDataType = null;
219 			    if (queryParams != null) {
220 			        for (QueryParamInfo queryParam : queryParams) {
221 			            if (queryParam.getKey() != null && queryParam.getKey().equals(searchParam.getKey())) {
222 			                paramDataType = queryParam.getFieldDescriptor().getDataType();
223 			            }
224 			        }
225 			    }
226 			    
227                 Object queryParamValue = null;
228 			    if ("date".equals(paramDataType)) {
229 			        try {
230                         queryParamValue = df.get().parse((String)searchParam.getValues().get(0));
231                     } catch (ParseException e) {
232                         throw new RuntimeException("Failed to parse date value " + searchParam.getValues().get(0),e);
233                     }
234 			    } else if ("long".equals(paramDataType)){
235 			    	try{
236 			    		List<Long> longList = new ArrayList<Long>();
237 			    		if(searchParam.getValues()!=null){
238 			    			for(String value:searchParam.getValues()){
239 			    				longList.add(Long.parseLong(value));
240 			    			}
241 			    		}
242 				      	queryParamValue = longList;
243 		            } catch (NumberFormatException e) {
244 		                throw new RuntimeException("Failed to parse long value " + searchParam.getValues(),e);
245 		            }
246 
247 			    } else if ("int".equals(paramDataType)){
248                     try{
249                         List<Integer> intList = new ArrayList<Integer>();
250                         if(searchParam.getValues()!=null){
251                             for(String value:searchParam.getValues()){
252                                 intList.add(Integer.parseInt(value));
253                             }
254                         }
255                         queryParamValue = intList;
256                     } catch (NumberFormatException e) {
257                         throw new RuntimeException("Failed to parse int value " + searchParam.getValues(),e);
258                     }
259 
260                 }  else {
261 			        queryParamValue = searchParam.getValues();
262 			    }
263 			    //Needed to get around Hibernate not supporting IN(:var) where var is null or an empty collection
264 			    if((queryParamValue==null||queryParamValue instanceof Collection && ((Collection<?>)queryParamValue).isEmpty())&&"list".equals(paramDataType)){
265 			    	queryParamValue = "";
266 			    }
267 			    query.setParameter(searchParam.getKey().replace(".", "_"), queryParamValue);
268 			}
269 		}
270 
271 		// Turn into results
272 		List<SearchResultRowInfo> results = convertToResults(query.getResultList(),searchTypeInfo);
273 
274 		SearchResultInfo searchResult = new SearchResultInfo();
275 		searchResult.setRows(results);
276 		searchResult.setSortColumn(searchRequest.getSortColumn());
277 		searchResult.setSortDirection(searchRequest.getSortDirection());
278 		searchResult.setStartAt(searchRequest.getStartAt());
279 		if(searchRequest.getNeededTotalResults()!=null && searchRequest.getNeededTotalResults()){
280 			//Get count of total rows if needed
281             String regex = "^\\s*[Ss][Ee][Ll][Ee][Cc][Tt]\\s+([^,\\s]+)(.|[\r\n])*?\\s+[Ff][Rr][Oo][Mm]\\s+";
282             String replacement = "SELECT COUNT(DISTINCT $1) FROM ";
283             queryString = queryString.replaceAll("([Dd][Ii][Ss][Tt][Ii][Nn][Cc][Tt])", "");
284 			String countQueryString = (queryString + optionalQueryString).replaceFirst(regex, replacement);
285 
286 			LOG.info("Executing query: {}", countQueryString);
287 			Query countQuery;
288 			if(isNative){
289 				countQuery = em.createNativeQuery(countQueryString);
290 			}else{
291 				countQuery = em.createQuery(countQueryString);
292 			}
293 			for (SearchParamInfo searchParam : internalQueryParms) {
294 			    // There is a bug here that happens with you pass a list of states in for lu_queryParam_luOptionalState
295 			    // When you pass in ('Approved' , 'Active' , 'Retired') the code below only grabbed the first value (it was
296 			    // hard coded with get(0). Thus, it would only count 'Approved' courses.  However, at UMD, there aren't even
297 			    // approved courses in the KSLU_CLU table (only 'Active') so we were getting an empty list back.
298 			    // Printout of searchParam.getValues():  [Approved, Active, Retired, null, null, null, null, null, null, null]
299 			    // You can see broken code only grabs the first value in the array using searchParam.getValues().get(0).
300 			    // select * from kslu_clu where st  IN ( 'Approved','Active', 'Retired' )
301 			    // Fix was to pass in the entire list of values when getValues() > 1
302 			  
303 			    if ( searchParam.getValues().size() == 1){
304 			        countQuery.setParameter(searchParam.getKey().replace(".", "_"), searchParam.getValues().get(0));
305 			    }
306 			    else {
307 			        countQuery.setParameter(searchParam.getKey().replace(".", "_"), searchParam.getValues());  
308 			    } 		
309 			}
310             Integer totalRecords = 0;
311             Object resultObject = countQuery.getSingleResult();
312             if (resultObject instanceof BigDecimal) {
313                 totalRecords = ((BigDecimal) resultObject).intValue();
314             } else if (resultObject instanceof Long) {
315                 totalRecords = ((Long) resultObject).intValue();
316             }
317             searchResult.setTotalResults(totalRecords);
318 		}
319 
320 		return searchResult;
321 	}
322 	
323 	private List<SearchResultRowInfo> convertToResults(List<?> queryResults,
324 			SearchTypeInfo searchTypeInfo) {
325 		List<SearchResultRowInfo> results = new ArrayList<SearchResultRowInfo>();
326 
327 		if(queryResults!=null){
328 			//Copy the query results to a Result object
329 			for(Object queryResult:queryResults){
330 				SearchResultRowInfo result = new SearchResultRowInfo();
331 				int i=0;
332 				for (ResultColumnInfo resultColumn : searchTypeInfo.getSearchResultTypeInfo().getResultColumns()) {
333 			
334 					SearchResultCellInfo resultCell = new SearchResultCellInfo();
335 					resultCell.setKey(resultColumn.getKey());
336 					
337 					try {
338 						Object queryResultCell = null;
339 						if(queryResult.getClass().isArray()){
340 							queryResultCell = ((Object[])queryResult)[i];
341 							
342 						}else{
343 							queryResultCell = queryResult;
344 						}
345 						
346 						if (queryResultCell != null) {
347 							resultCell.setValue(queryResultCell.toString());							
348 						}
349 					
350 					} catch (Exception e) {
351 						throw new RuntimeException("Error copying results from " + queryResult.toString(),e);
352 					}
353 					
354 					result.getCells().add(resultCell);
355 					i++;
356 				}
357 				results.add(result);
358 			}
359 		}
360 		return results;
361 	}
362 
363 }