View Javadoc
1   package com.randomnoun.common.spring;
2   
3   /* (c) 2013 randomnoun. All Rights Reserved. This work is licensed under a
4    * BSD Simplified License. (http://www.randomnoun.com/bsd-simplified.html)
5    */
6   
7   import java.sql.ResultSet;
8   import java.sql.SQLException;
9   import java.util.*;
10  
11  import org.springframework.dao.DataAccessException;
12  import org.springframework.jdbc.core.*;
13  
14  import org.apache.log4j.Logger;
15  
16  import com.randomnoun.common.Struct;
17  
18  // this could probably delegate to StructuredMapCallbackHandlerResultSetExtractor these days
19  // 
20  
21  /**
22   * This class returns structured object graphs composed of lists/maps.
23   * Think of it as a poor man's JDO, using Maps and Lists rather than concrete
24   * classes, which allows us to omit the POJO generation step.
25   *
26   * <p>This ResultReader is to be used in the JdbcTemplate class to retrieve structured
27   * representations of queries covering more than one table. In a simple case, say a
28   * cowGroup contains many cows, and we wish to return the cowGroup ID and name as
29   * well as the cow ID and name in the one query; typically, this would be done
30   * through the following hypothetical SQL:
31   *
32   * <pre class="code">
33   * SELECT cowGroupId, cowGroupName, cowId, cowName
34   * FROM cowGroups, cows
35   * WHERE cows.cowGroupId = cowGroups.cowGroupId
36   * </pre>
37   *
38   * Normally, Spring would return this as a List containing a map containing four
39   * keys (cowGroupId, cowGroupName, cowId and cowName), containing the values of
40   * each column returned by the RDBMS; e.g. the code fragment
41   *
42   * <pre class="code">
43               List result1 = jt.queryForList(sql);
44               System.out.println(Text.structuredListToString("result1", result1));
45     </pre>
46               (with the SQL above) would produce this output (a three-element list):
47   * <pre class="code">
48                result1 = [
49                  0 = {
50                    COWGROUPID =&gt; 1
51                    COWGROUPNAME =&gt; 'ROLLINGFIELD7'
52                    COWID =&gt; 1000
53                    COWNAME =&gt; 'DAISY'
54                  }
55                  1 = {
56                    COWGROUPID =&gt; 2
57                    COWGROUPNAME =&gt; 'SLAUGHTERHOUSE5'
58                    COWID =&gt; 1001
59                    COWNAME =&gt; 'BUTTERCUP'
60                  }
61                  2 = {
62                    COWGROUPID =&gt; 2
63                    COWGROUPNAME =&gt; 'SLAUGHTERHOUSE5'
64                    COWID =&gt; 1002
65                    COWNAME =&gt; 'STEVE'
66                  }
67                ]
68                </pre>
69   *
70   * note how both BUTTERCUP and STEVE belong to the SLAUGHTERHOUSE5 cowGroup, but
71   * DAISY is within it's own cowGroup. If we wanted to express this relationship
72   * by having a list of cows within each cowgroup, we would have to scan through
73   * this list manually, and filter each cow into it's parent cowGroup.
74   *
75   * <p>This object automates this process; e.g. the same structure above, when
76   * processed using the following code snippet:
77   *
78   * <pre class="code">
79                String mapping = "cowGroupId, cowGroupName, " +
80                  "cowId AS cows.cowId, cowName AS cows.cowName";
81                List result2 = jt.query(sql, new StructuredResultReader(mapping));
82                System.out.println(Text.structuredListToString("result2", result2));
83               </pre>
84   * ... would produce this output (a two-element list) ...
85               <pre style="code">
86                result2 = [
87                  0 = {
88                    cowGroupName =&gt; 'ROLLINGFIELD7'
89                    cowGroupId =&gt; 2
90                    cows = [
91                      0 = {
92                        cowId =&gt; 1000
93                        cowName =&gt; 'DAISY'
94                      }
95                    ]
96                  }
97                  1 = {
98                    cowGroupName =&gt; 'SLAUGHTERHOUSE5'
99                    cowGroupId =&gt; 1
100                   cows = [
101                     0 = {
102                       cowId =&gt; 1001
103                       cowName =&gt; 'BUTTERCUP'
104                     }
105                     1 = {
106                       cowId =&gt; 1002
107                       cowName =&gt; 'STEVE'
108                     }
109                   ]
110                 }
111               ]
112               </pre>
113  *
114  * which may be more suitable for processing. This structure can also be used within
115  * JSTL, e.g.
116  *
117  * <pre class="code">
118  *   &lt;c:out value="${result2[0].cows[1].cowName}"/&gt;
119  * </pre>
120  *
121  * would generate the text "STEVE".
122  *
123  * <p>This class can generate an arbitrary number of levels within the returned structure
124  * by specifying more groupings in the mapping text. This class also performs
125  * camelCasing of returned columns (e.g. to return 'cowName' rather than 'COWNAME').
126  *
127  * <p>The guts of the interface lies in the mapping string passed to the StructuredResultReader
128  * constructor. The syntax of this mapping string is a list of column names, separated by
129  * commas; a column name may also have an "AS" definition, which defines how it
130  * is to be represented in the returned structure.
131  *
132  * <i>e.g.</i> assuming a department contains many people, this mapping will group each
133  * person within their department:
134  *
135  * <pre class="code">
136  * departmentName, personName AS people.name, personId AS people.id
137  * </pre>
138  *
139  * which will return a List of Maps, containing two keys, 'departmentName' and 'people'.
140  * The people value will be a List of Maps, containing two keys, 'name' and 'id'.
141  *
142  * <p>If each person now has a number of addresses, we could perform a two-level grouping,
143  * using the following mapping:
144  *
145  * <pre class="code">
146  * department, personName AS people.name, personId AS people.id,
147  * addressType AS people.addresses.type
148  * </pre>
149  *
150  * <p>Each people value will now be a List of Maps containing three keys, 'name', 'id' and
151  * 'addresses'; the 'addresses' value will be a List of Maps with only one key, 'type'.
152  *
153  * <p>Hope that all makes sense.
154  *
155  * <p>Implementation note: The resultset returned by the SQL must be sorted by
156  * the top-level group, then by the second-level group, and so on, for this code to
157  * work as expected.
158  *
159  * 
160  * @author knoxg
161  */
162 public class StructuredListResultSetExtractor
163     implements ResultSetExtractor<Object> {
164     
165     
166     
167     /** Logger instance for this class */
168     private static Logger logger = Logger.getLogger(StructuredListResultSetExtractor.class);  
169     
170     /** Contains mappings from source column names to (structured) target column names */
171     Map<String, String> columnMapping;
172 
173     /** List to save results in */
174     private final List<Map<String, Object>> results;
175     private List<String> levels;
176 
177     /** Row mapper */
178     private final RowMapper<Map<String, Object>> rowMapper;
179     private Map<String, Object> lastResultRow = null;
180 
181     /** The counter used to count rows */
182     private int rowNum = 0;
183 
184     /**
185      * Create a new RowMapperResultReader.
186      * @param rowMapper the RowMapper which creates an object for each row
187      */
188     public StructuredListResultSetExtractor(JdbcTemplate jt, String mappings) {
189         this(new ColumnMapRowMapper(), mappings, 0);
190     }
191 
192     /**
193      * Create a new RowMapperResultReader.
194      * @param rowMapper the RowMapper which creates an object for each row
195      * @param rowsExpected the number of expected rows
196      * (just used for optimized collection handling)
197      */
198     public StructuredListResultSetExtractor(RowMapper<Map<String, Object>> rowMapper, String mappings, int rowsExpected) {
199         
200         if (mappings == null) { throw new NullPointerException("mappings cannot be null"); }
201 
202         // Use the more efficient collection if we know how many rows to expect:
203         // ArrayList in case of a known row count, LinkedList if unknown
204         this.results = (rowsExpected > 0) ? new ArrayList<Map<String, Object>>(rowsExpected) : new LinkedList<Map<String, Object>>();
205         this.rowMapper = rowMapper;
206         this.columnMapping = new HashMap<String, String>();
207         this.levels = new ArrayList<String>(3); // we're not going to go higher than this too often
208 
209         StringTokenizer st = new StringTokenizer(mappings, ",");
210         StringTokenizer st2;
211         StringTokenizer st3;
212         String column = null;
213         String columnTarget = null;
214         String token;
215         String mapping;
216 
217         while (st.hasMoreTokens()) {
218             mapping = st.nextToken().trim();
219 
220             if (mapping.indexOf(' ') == -1) {
221                 column = mapping;
222                 columnTarget = mapping;
223             } else {
224                 // parse state (note that this uses a StringTokenizer, 
225                 // rather than a character-based parser)
226                 // 
227                 // 0 = start of parse
228                 // 1 = consumed column name
229                 // 2 = consumed 'as'
230                 // 3 = consumed mapping
231                 int state = 0; // 0=initial, 1=got column name, 2=got 'as', 3=got mapping
232                 st2 = new StringTokenizer(mapping, " ");
233                 while (st2.hasMoreTokens()) {
234                     token = st2.nextToken().trim();
235                     if (token.equals("")) { continue; }
236 
237                     if (state == 0) {
238                         column = token;
239                         state = 1;
240                     } else if (state == 1) {
241                         if (!token.equalsIgnoreCase("as")) {
242                             throw new IllegalArgumentException("Invalid mapping '" + mapping + "'; expected AS");
243                         }
244                         state = 2;
245                     } else if (state == 2) {
246                         columnTarget = token;
247                         state = 3;
248                     } else if (state == 3) {
249                         throw new IllegalArgumentException("Invalid mapping '" + mapping + "'; too many tokens");
250                     }
251                 }
252             }
253 
254             // check target for levels
255             int levelIdx = 0;
256             st3 = new StringTokenizer(columnTarget, ".");
257             if (st3.hasMoreTokens()) {
258                 String level = st3.nextToken();
259 
260                 while (st3.hasMoreTokens()) {
261                     if (levelIdx < levels.size()) {
262                         if (!levels.get(levelIdx).equals(level)) {
263                             throw new IllegalArgumentException("Multiple lists in mapping at level " + levelIdx + ": '" + levels.get(levelIdx) + "' and '" + level + "'");
264                         }
265                     } else {
266                         levels.add(level);
267                         // System.out.println("Levels now: " + levels);
268                     }
269                     level = st3.nextToken();
270                     levelIdx++;
271                 }
272             }
273             columnMapping.put(column.toUpperCase(), columnTarget);
274         }
275     }
276 
277 	/** Required to support ResultSetExtractor interface
278 	 * 
279 	 * @param rs resultSet to process
280 	 * 
281 	 * @return a List of structured Maps, as described in the class javadoc
282 	 */
283     public Object extractData(ResultSet rs) throws SQLException, DataAccessException 
284 	{
285 		// int rowNum = 0;
286 		while (rs.next()) {
287 			// results.add(this.rowMapper.mapRow(rs, rowNum++));
288 			processRow(rs);
289 		}
290 		return results;
291 	}
292     
293     
294     /**
295      * Used by the ResultReader interface to return the results read by this class
296      * 
297      * @see org.springframework.jdbc.core.ResultReader#getResults()
298      *
299     public List getResults() {
300         return results;
301     }
302     */
303 
304     /**
305      * Used by the ResultReader interface to process a single row from the database.
306      * 
307      * <p>The row is read and matched against the 'levels' specified in the 
308      * object constructor. As values change, tree branches are created in the returned
309      * structured List. 
310      * 
311      * @see org.springframework.jdbc.core.RowCallbackHandler#processRow(java.sql.ResultSet)
312      */
313     @SuppressWarnings("unchecked")
314 	public void processRow(ResultSet rs)
315         throws SQLException {
316         Map<String, Object> row = (Map<String, Object>) rowMapper.mapRow(rs, this.rowNum++); // ClobRowMapper always returns a Map.
317 		// System.out.println("Processing row " + Struct.structuredMapToString("row", row));
318 
319         // logger.debug("row is " + Struct.structuredMapToJson(row));
320         
321         int createLevel = 0;
322         List<Map<String, Object>> createList = results;
323         Map<String, Object> createRow = new HashMap<String, Object>();
324         String createPrefix = "";
325 
326         // determine highest level that we can create at
327         if (lastResultRow != null) {
328             // System.out.println("lastResultRow processing");
329             createLevel = levels.size() + 1;
330 
331             // find lowest level that has a different value
332             for (Iterator<Map.Entry<String, String>> i = columnMapping.entrySet().iterator(); i.hasNext();) {
333                 Map.Entry<String, String> entry = (Map.Entry<String, String>) i.next();
334                 String column = (String) entry.getKey();
335                 String columnTarget = (String) entry.getValue();
336 
337                 List<Map<String,Object>> containerList = results;
338                 Map<String, Object> containerMap = lastResultRow;
339 
340                 String component;
341                 int pos = columnTarget.indexOf('.');
342                 int level = 0;
343 
344                 while (pos != -1) {
345                     component = columnTarget.substring(0, pos);
346                     columnTarget = columnTarget.substring(pos + 1);
347 
348                     if (!containerMap.containsKey(component)) {
349                         throw new IllegalStateException("Missing field '" + component + "' in " + Struct.structuredMapToString("containerMap", containerMap) + "; last result row is " + Struct.structuredMapToString("lastResultRow", lastResultRow));
350                     }
351 
352                     if (component.equals(levels.get(level))) {
353                         level++;
354                         containerList = (List<Map<String,Object>>) containerMap.get(component);
355                         containerMap = (Map<String, Object>) containerList.get(containerList.size() - 1);
356                         if (containerMap==null) {
357                         	logger.error("null containerMap");
358                         }
359                     } else {
360                         containerMap = (Map<String, Object>) containerMap.get(component);
361                         if (containerMap==null) {
362                         	logger.error("null containerMap");
363                         }
364 
365                     }
366 
367                     pos = columnTarget.indexOf('.');
368                 }
369 
370                 Object thisValue = row.get(column);
371                 Object lastValue = containerMap.get(columnTarget);
372                 // System.out.println("Comparing thisValue '" + thisValue + "' to lastValue '" + lastValue + "'");
373 
374                 if ((thisValue == null && lastValue != null) || (thisValue != null && !thisValue.equals(lastValue))) {
375                     // values are different; create row
376                     if (createLevel > level) {
377                         createList = containerList;
378 
379                         // System.out.println("Reducing level to '" + level + "' because of " +
380                         //   "column '" + columnTarget + "' differing (" + thisValue + " instead of previous: " + lastValue);
381                         createLevel = level;
382                     }
383                 }
384             }
385         }
386 
387         if (createLevel > levels.size()) {
388             // rows are completely identical -- don't add it to the list
389             return;
390         }
391 
392         for (int i = 0; i < createLevel; i++) {
393             createPrefix = createPrefix + levels.get(i) + ".";
394         }
395 
396         // generate 'createRow'
397         for (Iterator<Map.Entry<String, String>> i = columnMapping.entrySet().iterator(); i.hasNext(); ) {
398         	Map.Entry<String, String> entry = i.next();
399             String column = (String) entry.getKey();
400             String columnTarget = (String) entry.getValue();
401             if (!columnTarget.startsWith(createPrefix)) {
402                 continue;
403             }
404             Object value = row.get(column); 
405 
406             //logger.debug("About to add column '" + columnTarget + "' from rs column '" + column + "'; createPrefix = '" + createPrefix + "' with value '" + value + "'");
407             
408             columnTarget = columnTarget.substring(createPrefix.length());
409             //logger.debug("  columnTarget '" + columnTarget + "'");
410 
411             List<Map<String, Object>> containerList = createList;
412             Map<String, Object> containerMap = createRow;
413             int level = createLevel;  // ** was 0 ?
414             String component;
415             int pos = columnTarget.indexOf('.');
416 
417             while (pos != -1) {
418                 component = columnTarget.substring(0, pos);
419                 columnTarget = columnTarget.substring(pos + 1);
420 
421                 /* if (!containerMap.containsKey(component)) {
422                    containerMap.put(component, new HashMap());
423                    } */
424 
425                 // System.out.println("Setting component '" + component + "' in " + 
426                 //   Codec.structuredMapToString("containerMap", containerMap));
427                 if (component.equals(levels.get(level))) {
428                     level++;
429                     containerList = (List<Map<String, Object>>) containerMap.get(component);
430 
431                     if (containerList == null) {
432                         containerList = new ArrayList<Map<String,Object>>();
433                         containerMap.put(component, containerList);
434                         containerList.add(new HashMap<String, Object>());
435                     }
436 
437                     containerMap = (Map<String, Object>) containerList.get(containerList.size() - 1);
438                     if (containerMap==null) {
439                     	logger.error("C null containerMap");
440                     }
441 
442                 } else {
443                     containerMap = (Map<String, Object>) containerMap.get(component);
444                     if (containerMap==null) {
445                     	logger.error("D null containerMap");
446                     }
447 
448                 }
449 
450                 pos = columnTarget.indexOf('.');
451             }
452 
453             containerMap.put(columnTarget, value);
454         }
455 
456         createList.add(createRow);
457         lastResultRow = (Map<String, Object>) results.get(results.size() - 1);
458         /*
459         if (logger.isDebugEnabled()) {
460             logger.debug("processRow complete; added row " + Struct.structuredMapToString("createRow", createRow));
461             logger.debug("resultSet now = " + Struct.structuredListToString("results", results));
462         }
463         */
464     }
465 
466 
467 }