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 => 1
51 COWGROUPNAME => 'ROLLINGFIELD7'
52 COWID => 1000
53 COWNAME => 'DAISY'
54 }
55 1 = {
56 COWGROUPID => 2
57 COWGROUPNAME => 'SLAUGHTERHOUSE5'
58 COWID => 1001
59 COWNAME => 'BUTTERCUP'
60 }
61 2 = {
62 COWGROUPID => 2
63 COWGROUPNAME => 'SLAUGHTERHOUSE5'
64 COWID => 1002
65 COWNAME => '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 => 'ROLLINGFIELD7'
89 cowGroupId => 2
90 cows = [
91 0 = {
92 cowId => 1000
93 cowName => 'DAISY'
94 }
95 ]
96 }
97 1 = {
98 cowGroupName => 'SLAUGHTERHOUSE5'
99 cowGroupId => 1
100 cows = [
101 0 = {
102 cowId => 1001
103 cowName => 'BUTTERCUP'
104 }
105 1 = {
106 cowId => 1002
107 cowName => '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 * <c:out value="${result2[0].cows[1].cowName}"/>
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 }