001package com.randomnoun.common.spring;
002
003/* (c) 2013 randomnoun. All Rights Reserved. This work is licensed under a
004 * BSD Simplified License. (http://www.randomnoun.com/bsd-simplified.html)
005 */
006
007import java.sql.ResultSet;
008import java.sql.SQLException;
009import java.util.*;
010
011import org.springframework.dao.DataAccessException;
012import org.springframework.jdbc.core.*;
013
014import org.apache.log4j.Logger;
015
016import com.randomnoun.common.Struct;
017
018// this could probably delegate to StructuredMapCallbackHandlerResultSetExtractor these days
019// 
020
021/**
022 * This class returns structured object graphs composed of lists/maps.
023 * Think of it as a poor man's JDO, using Maps and Lists rather than concrete
024 * classes, which allows us to omit the POJO generation step.
025 *
026 * <p>This ResultReader is to be used in the JdbcTemplate class to retrieve structured
027 * representations of queries covering more than one table. In a simple case, say a
028 * cowGroup contains many cows, and we wish to return the cowGroup ID and name as
029 * well as the cow ID and name in the one query; typically, this would be done
030 * through the following hypothetical SQL:
031 *
032 * <pre class="code">
033 * SELECT cowGroupId, cowGroupName, cowId, cowName
034 * FROM cowGroups, cows
035 * WHERE cows.cowGroupId = cowGroups.cowGroupId
036 * </pre>
037 *
038 * Normally, Spring would return this as a List containing a map containing four
039 * keys (cowGroupId, cowGroupName, cowId and cowName), containing the values of
040 * each column returned by the RDBMS; e.g. the code fragment
041 *
042 * <pre class="code">
043             List result1 = jt.queryForList(sql);
044             System.out.println(Text.structuredListToString("result1", result1));
045   </pre>
046             (with the SQL above) would produce this output (a three-element list):
047 * <pre class="code">
048              result1 = [
049                0 = {
050                  COWGROUPID =&gt; 1
051                  COWGROUPNAME =&gt; 'ROLLINGFIELD7'
052                  COWID =&gt; 1000
053                  COWNAME =&gt; 'DAISY'
054                }
055                1 = {
056                  COWGROUPID =&gt; 2
057                  COWGROUPNAME =&gt; 'SLAUGHTERHOUSE5'
058                  COWID =&gt; 1001
059                  COWNAME =&gt; 'BUTTERCUP'
060                }
061                2 = {
062                  COWGROUPID =&gt; 2
063                  COWGROUPNAME =&gt; 'SLAUGHTERHOUSE5'
064                  COWID =&gt; 1002
065                  COWNAME =&gt; 'STEVE'
066                }
067              ]
068              </pre>
069 *
070 * note how both BUTTERCUP and STEVE belong to the SLAUGHTERHOUSE5 cowGroup, but
071 * DAISY is within it's own cowGroup. If we wanted to express this relationship
072 * by having a list of cows within each cowgroup, we would have to scan through
073 * this list manually, and filter each cow into it's parent cowGroup.
074 *
075 * <p>This object automates this process; e.g. the same structure above, when
076 * processed using the following code snippet:
077 *
078 * <pre class="code">
079              String mapping = "cowGroupId, cowGroupName, " +
080                "cowId AS cows.cowId, cowName AS cows.cowName";
081              List result2 = jt.query(sql, new StructuredResultReader(mapping));
082              System.out.println(Text.structuredListToString("result2", result2));
083             </pre>
084 * ... would produce this output (a two-element list) ...
085             <pre style="code">
086              result2 = [
087                0 = {
088                  cowGroupName =&gt; 'ROLLINGFIELD7'
089                  cowGroupId =&gt; 2
090                  cows = [
091                    0 = {
092                      cowId =&gt; 1000
093                      cowName =&gt; 'DAISY'
094                    }
095                  ]
096                }
097                1 = {
098                  cowGroupName =&gt; 'SLAUGHTERHOUSE5'
099                  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 */
162public 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}