Coverage Summary for Class: MetaData (net.sf.persism)
| Class | Class, % | Method, % | Line, % |
|---|---|---|---|
| MetaData | 100% (1/1) | 100% (37/37) | 96.6% (588/609) |
1 package net.sf.persism; 2 3 import net.sf.persism.annotations.*; 4 5 import java.lang.annotation.Annotation; 6 import java.lang.reflect.Field; 7 import java.lang.reflect.Method; 8 import java.lang.reflect.Modifier; 9 import java.sql.*; 10 import java.text.MessageFormat; 11 import java.util.*; 12 import java.util.concurrent.ConcurrentHashMap; 13 14 import static net.sf.persism.Util.*; 15 16 /** 17 * DB and POJO related Metadata collected based connection url 18 * 19 * @author Dan Howard 20 * @since 3/31/12 4:19 PM 21 */ 22 final class MetaData { 23 24 private static final Log log = Log.getLogger(MetaData.class); 25 26 // properties for each class - static because this won't need to change between MetaData instances (collection is unmodifiable) 27 private static final Map<Class<?>, Collection<PropertyInfo>> propertyMap = new ConcurrentHashMap<>(32); 28 29 // column to property map for each class 30 private final Map<Class<?>, Map<String, PropertyInfo>> propertyInfoMap = new ConcurrentHashMap<>(32); 31 private final Map<Class<?>, Map<String, ColumnInfo>> columnInfoMap = new ConcurrentHashMap<>(32); 32 33 // SQL for updates/inserts/deletes/selects for each class 34 private final Map<Class<?>, String> updateStatementsMap = new ConcurrentHashMap<>(32); 35 private final Map<Class<?>, String> deleteStatementsMap = new ConcurrentHashMap<>(32); 36 private final Map<Class<?>, String> selectStatementsMap = new ConcurrentHashMap<>(32); 37 38 // key - class, value - map key: columns to include, value: associated INSERT statement - this handles defaults on columns which may not need to be specified 39 private final Map<Class<?>, Map<String, String>> insertStatements = new ConcurrentHashMap<>(32); 40 41 // key - class, value - map key: changed columns, value: associated UPDATE statement 42 private final Map<Class<?>, Map<String, String>> variableUpdateStatements = new ConcurrentHashMap<>(32); 43 44 // Where clauses for primary key queries for tables 45 private final Map<Class<?>, String> primaryWhereClauseMap = new ConcurrentHashMap<>(32); 46 47 // Where ID IN (?, ?, ?) kinds of queries when no SQL is used. 48 private final Map<Class<?>, Map<Integer, String>> primaryInClauseMap = new ConcurrentHashMap<>(32); 49 50 // SQL parsed from SQL.where() - key is WHERE, value is parsed where clause 51 Map<Class<?>, Map<String, String>> whereClauses = new ConcurrentHashMap<>(32); 52 53 // WHERE clauses defined by JOIN operations (maintained by SessionHelper) 54 Map<JoinInfo, Map<String, String>> childWhereClauses = new ConcurrentHashMap<>(32); 55 56 // table/view for each class 57 private final Map<Class<?>, TableInfo> tableOrViewMap = new ConcurrentHashMap<>(32); 58 59 // list of tables in the DB 60 private final Set<TableInfo> tables = new HashSet<>(); 61 62 // list of views in the DB 63 private final Set<TableInfo> views = new HashSet<>(); 64 65 static final Map<String, MetaData> metaData = new ConcurrentHashMap<>(4); 66 67 private final ConnectionType connectionType; 68 69 // the "extra" characters that can be used in unquoted identifier names (those beyond a-z, A-Z, 0-9 and _) 70 // Was using DatabaseMetaData getExtraNameCharacters() but some drivers don't provide these and still allow 71 // for non-alphanumeric characters in column names. We'll just use a static set. 72 private static final String EXTRA_NAME_CHARACTERS = "`~!@#$%^&*()-+=/|\\{}[]:;'\".,<>*"; 73 private static final String SELECT_FOR_COLUMNS = "SELECT * FROM {0}{1}{2} WHERE 1=0"; 74 private static final String SELECT_FOR_COLUMNS_WITH_SCHEMA = "SELECT * FROM {0}{1}{2}.{3}{4}{5} WHERE 1=0"; 75 76 private MetaData(Connection con, String sessionKey) throws SQLException { 77 78 log.debug("MetaData CREATING instance [%s] ", sessionKey); 79 80 connectionType = ConnectionType.get(sessionKey); 81 if (connectionType == ConnectionType.Other) { 82 log.warn(Message.UnknownConnectionType.message(con.getMetaData().getDatabaseProductName())); 83 } 84 populateTableList(con); 85 } 86 87 static synchronized MetaData getInstance(Connection con, String sessionKey) throws SQLException { 88 89 if (sessionKey == null) { 90 sessionKey = con.getMetaData().getURL(); 91 } 92 93 if (metaData.get(sessionKey) == null) { 94 metaData.put(sessionKey, new MetaData(con, sessionKey)); 95 } 96 log.debug("MetaData getting instance %s", sessionKey); 97 return metaData.get(sessionKey); 98 } 99 100 // Should only be called IF the map does not contain the column meta information yet. 101 // Version for Tables 102 private synchronized <T> Map<String, PropertyInfo> determinePropertyInfo(Class<T> objectClass, TableInfo table, Connection connection) { 103 // double check map 104 if (propertyInfoMap.containsKey(objectClass)) { 105 return propertyInfoMap.get(objectClass); 106 } 107 108 // Not for @NotTable classes 109 assert objectClass.getAnnotation(NotTable.class) == null; 110 111 String sd = connectionType.getKeywordStartDelimiter(); 112 String ed = connectionType.getKeywordEndDelimiter(); 113 114 ResultSet rs = null; 115 Statement st = null; 116 try { 117 st = connection.createStatement(); 118 // gives us real column names with case. 119 String sql; 120 if (isEmpty(table.schema())) { 121 sql = MessageFormat.format(SELECT_FOR_COLUMNS, sd, table.name(), ed); 122 } else { 123 sql = MessageFormat.format(SELECT_FOR_COLUMNS_WITH_SCHEMA, sd, table.schema(), ed, sd, table.name(), ed); 124 } 125 if (log.isDebugEnabled()) { 126 log.debug("determineColumns: %s", sql); 127 } 128 rs = st.executeQuery(sql); 129 Map<String, PropertyInfo> columns = determinePropertyInfoFromResultSet(objectClass, rs); 130 propertyInfoMap.put(objectClass, columns); 131 return columns; 132 } catch (SQLException e) { 133 throw new PersismException(e.getMessage(), e); 134 } finally { 135 cleanup(st, rs); 136 } 137 } 138 139 private <T> Map<String, PropertyInfo> determinePropertyInfoFromResultSet(Class<T> objectClass, ResultSet rs) throws SQLException { 140 ResultSetMetaData rsmd = rs.getMetaData(); 141 Collection<PropertyInfo> properties = getPropertyInfo(objectClass); 142 143 int columnCount = rsmd.getColumnCount(); 144 145 Map<String, PropertyInfo> columns = new LinkedHashMap<>(columnCount); 146 for (int j = 1; j <= columnCount; j++) { 147 String realColumnName = rsmd.getColumnLabel(j); 148 String columnName = realColumnName.toLowerCase().replace("_", "").replace(" ", ""); 149 // also replace these characters 150 for (int x = 0; x < EXTRA_NAME_CHARACTERS.length(); x++) { 151 columnName = columnName.replace("" + EXTRA_NAME_CHARACTERS.charAt(x), ""); 152 } 153 PropertyInfo foundProperty = null; 154 for (PropertyInfo propertyInfo : properties) { 155 String checkName = propertyInfo.propertyName.toLowerCase().replace("_", ""); 156 if (checkName.equalsIgnoreCase(columnName)) { 157 foundProperty = propertyInfo; 158 break; 159 } else { 160 // check annotation against column name 161 Column column = (Column) propertyInfo.getAnnotation(Column.class); 162 if (column != null) { 163 if (column.name().equalsIgnoreCase(realColumnName)) { 164 foundProperty = propertyInfo; 165 break; 166 } 167 } 168 } 169 } 170 171 if (foundProperty != null) { 172 columns.put(realColumnName, foundProperty); 173 } else { 174 log.warn(Message.NoPropertyFoundForColumn.message(realColumnName, objectClass)); 175 } 176 } 177 return columns; 178 } 179 180 @SuppressWarnings({"JDBCExecuteWithNonConstantString", "SqlDialectInspection"}) 181 private synchronized <T> Map<String, ColumnInfo> determineColumnInfo(Class<T> objectClass, TableInfo table, Connection connection) { 182 if (columnInfoMap.containsKey(objectClass)) { 183 return columnInfoMap.get(objectClass); 184 } 185 186 Statement st = null; 187 ResultSet rs = null; 188 Map<String, PropertyInfo> properties = getTableColumnsPropertyInfo(objectClass, connection); 189 String sd = connectionType.getKeywordStartDelimiter(); 190 String ed = connectionType.getKeywordEndDelimiter(); 191 192 String schemaName = table.schema(); 193 String tableName = table.name(); 194 195 try { 196 st = connection.createStatement(); 197 String sql; 198 if (isEmpty(schemaName)) { 199 sql = MessageFormat.format(SELECT_FOR_COLUMNS, sd, tableName, ed); 200 } else { 201 sql = MessageFormat.format(SELECT_FOR_COLUMNS_WITH_SCHEMA, sd, schemaName, ed, sd, tableName, ed); 202 } 203 log.debug("determineColumnInfo %s", sql); 204 rs = st.executeQuery(sql); 205 206 // Make sure primary keys sorted by column order in case we have more than 1 207 // then we'll know the order to apply the parameters. 208 Map<String, ColumnInfo> map = new LinkedHashMap<>(32); 209 210 boolean primaryKeysFound = false; 211 212 // Grab all columns and make first pass to detect primary auto-inc 213 ResultSetMetaData rsMetaData = rs.getMetaData(); 214 for (int i = 1; i <= rsMetaData.getColumnCount(); i++) { 215 // only include columns where we have a property 216 if (properties.containsKey(rsMetaData.getColumnLabel(i))) { 217 ColumnInfo columnInfo = new ColumnInfo(); 218 columnInfo.columnName = rsMetaData.getColumnLabel(i); 219 columnInfo.autoIncrement = rsMetaData.isAutoIncrement(i); 220 columnInfo.primary = columnInfo.autoIncrement; 221 columnInfo.sqlColumnType = rsMetaData.getColumnType(i); 222 columnInfo.sqlColumnTypeName = rsMetaData.getColumnTypeName(i); 223 columnInfo.columnType = JavaType.convert(columnInfo.sqlColumnType); 224 columnInfo.length = rsMetaData.getColumnDisplaySize(i); 225 226 if (!primaryKeysFound) { 227 primaryKeysFound = columnInfo.primary; 228 } 229 230 PropertyInfo propertyInfo = properties.get(rsMetaData.getColumnLabel(i)); 231 Annotation annotation = propertyInfo.getAnnotation(Column.class); 232 233 if (annotation != null) { 234 Column col = (Column) annotation; 235 if (col.hasDefault()) { 236 columnInfo.hasDefault = true; 237 } 238 239 if (col.primary()) { 240 columnInfo.primary = true; 241 if (!isDataClassATable(objectClass)) { 242 log.warn(Message.PrimaryAnnotationOnViewOrQueryMakesNoSense.message(objectClass, propertyInfo.propertyName)); 243 } 244 } 245 246 if (col.autoIncrement()) { 247 columnInfo.autoIncrement = true; 248 if (!columnInfo.columnType.isEligibleForAutoinc()) { 249 // This will probably cause some error or other problem. Notify the user. 250 log.warn(Message.ColumnAnnotatedAsAutoIncButNAN.message(columnInfo.columnName, columnInfo.columnType)); 251 } 252 } 253 254 columnInfo.readOnly = col.readOnly(); 255 256 if (!primaryKeysFound) { 257 primaryKeysFound = columnInfo.primary; 258 } 259 } 260 261 map.put(columnInfo.columnName, columnInfo); 262 } 263 } 264 rs.close(); 265 266 DatabaseMetaData dmd = connection.getMetaData(); 267 268 if (objectClass.getAnnotation(View.class) == null) { 269 270 if (isEmpty(schemaName)) { 271 rs = dmd.getPrimaryKeys(null, connectionType.getSchemaPattern(), tableName); 272 } else { 273 rs = dmd.getPrimaryKeys(null, schemaName, tableName); 274 } 275 int primaryKeysCount = 0; 276 while (rs.next()) { 277 ColumnInfo columnInfo = map.get(rs.getString("COLUMN_NAME")); 278 if (columnInfo != null) { 279 columnInfo.primary = true; 280 281 if (!primaryKeysFound) { 282 primaryKeysFound = columnInfo.primary; 283 } 284 } 285 primaryKeysCount++; 286 } 287 288 if (primaryKeysCount == 0 && !primaryKeysFound) { 289 log.warn(Message.DatabaseMetaDataCouldNotFindPrimaryKeys.message(table)); 290 } 291 } 292 293 /* 294 Get columns from database metadata since we don't get Type from resultSetMetaData 295 with SQLite. + We also need to know if there's a default on a column. 296 */ 297 if (isEmpty(schemaName)) { 298 rs = dmd.getColumns(null, connectionType.getSchemaPattern(), tableName, null); 299 } else { 300 rs = dmd.getColumns(null, schemaName, tableName, null); 301 } 302 int columnsCount = 0; 303 while (rs.next()) { 304 ColumnInfo columnInfo = map.get(rs.getString("COLUMN_NAME")); 305 if (columnInfo != null) { 306 if (!columnInfo.hasDefault) { 307 columnInfo.hasDefault = containsColumn(rs, "COLUMN_DEF") && rs.getString("COLUMN_DEF") != null; 308 } 309 310 // Do we not have autoinc info here? Yes. 311 // IS_AUTOINCREMENT = NO or YES 312 if (!columnInfo.autoIncrement) { 313 columnInfo.autoIncrement = containsColumn(rs, "IS_AUTOINCREMENT") && "YES".equalsIgnoreCase(rs.getString("IS_AUTOINCREMENT")); 314 } 315 316 // Re-assert the type since older version of SQLite could not detect types with empty resultsets 317 // It seems OK now in the newer JDBC driver. 318 // See testTypes unit test in TestSQLite 319 if (containsColumn(rs, "DATA_TYPE")) { 320 columnInfo.sqlColumnType = rs.getInt("DATA_TYPE"); 321 if (containsColumn(rs, "TYPE_NAME")) { 322 columnInfo.sqlColumnTypeName = rs.getString("TYPE_NAME"); 323 } 324 columnInfo.columnType = JavaType.convert(columnInfo.sqlColumnType); 325 } 326 } 327 columnsCount++; 328 } 329 rs.close(); 330 331 if (columnsCount == 0) { 332 // Shouldn't this be a fail? It would mean the user connecting to the DB 333 // has no permission to get the column meta-data 334 // It's a warning because it is possible to specify the column information 335 // with annotations rather than having Persism discover it. 336 log.warn(Message.DatabaseMetaDataCouldNotFindColumns.message(table)); 337 } 338 339 // FOR Oracle which doesn't set autoinc in metadata even if we have: 340 // "ID" NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY 341 // Apparently that's not enough for the Oracle JDBC driver to indicate this is autoinc. 342 // If we have a primary that's NUMERIC and HAS a default AND autoinc is not set then set it. 343 if (connectionType == ConnectionType.Oracle) { 344 Optional<ColumnInfo> autoInc = map.values().stream().filter(e -> e.autoIncrement).findFirst(); 345 if (autoInc.isEmpty()) { 346 // Do a second check if we have a primary that's numeric with a default. 347 Optional<ColumnInfo> primaryOpt = map.values().stream().filter(e -> e.primary).findFirst(); 348 if (primaryOpt.isPresent()) { 349 ColumnInfo primary = primaryOpt.get(); 350 if (primary.columnType.isEligibleForAutoinc() && primary.hasDefault) { 351 primary.autoIncrement = true; 352 primaryKeysFound = true; 353 } 354 } 355 } 356 } 357 358 if (!primaryKeysFound && isDataClassATable(objectClass)) { 359 // Should we fail-fast? Actually no, we should not fail here. 360 // It's very possible the user has a table that they will never 361 // update, delete or select (by primary). 362 // They may only want to do read operations with specified queries and in that 363 // context we don't need any primary keys. (same with insert) 364 log.warn(Message.NoPrimaryKeyFoundForTable.message(table)); 365 } 366 367 columnInfoMap.put(objectClass, map); 368 return map; 369 370 } catch (SQLException e) { 371 throw new PersismException(e.getMessage(), e); 372 } finally { 373 cleanup(st, rs); 374 } 375 } 376 377 private static boolean isDataClassATable(Class<?> objectClass) { 378 return objectClass.getAnnotation(Table.class) != null || (objectClass.getAnnotation(View.class) == null && objectClass.getAnnotation(NotTable.class) == null); 379 } 380 381 static <T> Collection<PropertyInfo> getPropertyInfo(Class<T> objectClass) { 382 if (propertyMap.containsKey(objectClass)) { 383 return propertyMap.get(objectClass); 384 } 385 return determinePropertyInfo(objectClass); 386 } 387 388 private static synchronized <T> Collection<PropertyInfo> determinePropertyInfo(Class<T> objectClass) { 389 if (propertyMap.containsKey(objectClass)) { 390 return propertyMap.get(objectClass); 391 } 392 393 Map<String, PropertyInfo> propertyInfos = new HashMap<>(32); 394 395 List<Field> fields = new ArrayList<>(32); 396 397 // getDeclaredFields does not get fields from super classes..... 398 fields.addAll(Arrays.asList(objectClass.getDeclaredFields())); 399 Class<?> sup = objectClass.getSuperclass(); 400 log.debug("fields for %s", sup); 401 while (!sup.equals(Object.class) && !sup.equals(PersistableObject.class)) { 402 fields.addAll(Arrays.asList(sup.getDeclaredFields())); 403 sup = sup.getSuperclass(); 404 log.debug("fields for %s", sup); 405 } 406 407 Method[] methods = objectClass.getMethods(); 408 409 for (Field field : fields) { 410 // Skip static fields 411 if (Modifier.isStatic(field.getModifiers())) { 412 continue; 413 } 414 // log.debug("Field Name: %s", field.getName()); 415 String propertyName = field.getName(); 416 // log.debug("Property Name: *%s* ", propertyName); 417 418 PropertyInfo propertyInfo = new PropertyInfo(); 419 propertyInfo.propertyName = propertyName; 420 propertyInfo.field = field; 421 Annotation[] annotations = field.getAnnotations(); 422 for (Annotation annotation : annotations) { 423 propertyInfo.annotations.put(annotation.annotationType(), annotation); 424 } 425 426 for (Method method : methods) { 427 String propertyNameToTest = field.getName().substring(0, 1).toUpperCase() + field.getName().substring(1); 428 // log.debug("property name for testing %s", propertyNameToTest); 429 if (propertyNameToTest.startsWith("Is") && propertyNameToTest.length() > 2 && Character.isUpperCase(propertyNameToTest.charAt(2))) { 430 propertyNameToTest = propertyName.substring(2); 431 } 432 433 String[] candidates = {"set" + propertyNameToTest, "get" + propertyNameToTest, "is" + propertyNameToTest, field.getName()}; 434 435 if (Arrays.asList(candidates).contains(method.getName())) { 436 //log.debug(" METHOD: %s", method.getName()); 437 438 annotations = method.getAnnotations(); 439 for (Annotation annotation : annotations) { 440 propertyInfo.annotations.put(annotation.annotationType(), annotation); 441 } 442 443 // OR added to fix to builder pattern style when your setters are just the field name 444 if (method.getName().equalsIgnoreCase("set" + propertyNameToTest) || method.getParameterCount() > 0) { 445 propertyInfo.setter = method; 446 } else { 447 propertyInfo.getter = method; 448 } 449 } 450 } 451 452 propertyInfo.isJoin = propertyInfo.getAnnotation(Join.class) != null; 453 propertyInfos.put(propertyName.toLowerCase(), propertyInfo); 454 } 455 456 // Remove any properties found with the NotColumn annotation 457 // http://stackoverflow.com/questions/2026104/hashmap-keyset-foreach-and-remove 458 Iterator<Map.Entry<String, PropertyInfo>> it = propertyInfos.entrySet().iterator(); 459 while (it.hasNext()) { 460 Map.Entry<String, PropertyInfo> entry = it.next(); 461 PropertyInfo info = entry.getValue(); 462 // added support for transient 463 if (info.getAnnotation(NotColumn.class) != null || Modifier.isTransient(info.field.getModifiers())) { 464 it.remove(); 465 } 466 } 467 468 Collection<PropertyInfo> properties = Collections.unmodifiableCollection(propertyInfos.values()); 469 propertyMap.put(objectClass, properties); 470 471 // If a view or query - warn if we find any setters 472 if (objectClass.getAnnotation(NotTable.class) != null || objectClass.getAnnotation(View.class) != null) { 473 List<String> setters = new ArrayList<>(); 474 for (PropertyInfo propertyInfo : properties) { 475 if (propertyInfo.setter != null) { 476 setters.add(propertyInfo.propertyName); 477 } 478 } 479 480 if (setters.size() > 0) { 481 log.warn(Message.SettersFoundInReadOnlyObject.message(objectClass, setters)); 482 } 483 } 484 485 return properties; 486 } 487 488 private static final String TABLE = "TABLE"; 489 private static final String VIEW = "VIEW"; 490 private static final String[] tableTypes = {TABLE, VIEW}; 491 492 // Populates the tables list with table names from the DB. 493 // This list is used for discovery of the table name from a class. 494 // ONLY to be called from Init in a synchronized way. 495 // NULL POINTER WITH 496 // http://social.msdn.microsoft.com/Forums/en-US/sqldataaccess/thread/5c74094a-8506-4278-ac1c-f07d1bfdb266 497 // solution: 498 // http://stackoverflow.com/questions/8988945/java7-sqljdbc4-sql-error-08s01-on-getconnection 499 private void populateTableList(Connection con) throws PersismException { 500 try (ResultSet rs = con.getMetaData().getTables(null, connectionType.getSchemaPattern(), null, tableTypes)) { 501 String name; 502 while (rs.next()) { 503 name = rs.getString("TABLE_NAME"); 504 if (VIEW.equalsIgnoreCase(rs.getString("TABLE_TYPE"))) { 505 views.add(new TableInfo(name, rs.getString("TABLE_SCHEM"), connectionType)); 506 } else { 507 tables.add(new TableInfo(name, rs.getString("TABLE_SCHEM"), connectionType)); 508 } 509 } 510 } catch (SQLException e) { 511 throw new PersismException(e.getMessage(), e); 512 } 513 } 514 515 /** 516 * @param object 517 * @param connection 518 * @return sql update string 519 * @throws NoChangesDetectedForUpdateException if the data object implements Persistable and there are no changes detected 520 */ 521 String getUpdateStatement(Object object, Connection connection) throws PersismException, NoChangesDetectedForUpdateException { 522 523 String sql; 524 if (object instanceof Persistable<?> pojo) { 525 Map<String, PropertyInfo> changes = getChangedProperties(pojo, connection); 526 if (changes.size() == 0) { 527 throw new NoChangesDetectedForUpdateException(); 528 } 529 530 Class<?> objectClass = object.getClass(); 531 String key = changes.keySet().toString(); 532 if (variableUpdateStatements.containsKey(objectClass) && variableUpdateStatements.get(objectClass).containsKey(key)) { 533 sql = variableUpdateStatements.get(objectClass).get(key); 534 } else { 535 sql = determineUpdateStatement(pojo, connection); 536 } 537 538 if (log.isDebugEnabled()) { 539 log.debug("getUpdateStatement for %s for changed fields is %s", objectClass, sql); 540 } 541 return sql; 542 } 543 544 if (updateStatementsMap.containsKey(object.getClass())) { 545 sql = updateStatementsMap.get(object.getClass()); 546 } else { 547 sql = determineUpdateStatement(object, connection); 548 } 549 if (log.isDebugEnabled()) { 550 log.debug("getUpdateStatement for: %s %s", object.getClass(), sql); 551 } 552 return sql; 553 } 554 555 // Used by Objects not implementing Persistable since they will always use the same update statement 556 private synchronized String determineUpdateStatement(Object object, Connection connection) { 557 if (updateStatementsMap.containsKey(object.getClass())) { 558 return updateStatementsMap.get(object.getClass()); 559 } 560 561 Class<?> objectClass = object.getClass(); 562 var columns = getColumns(objectClass, connection); 563 Map<String, PropertyInfo> propertyMap; 564 if (object instanceof Persistable<?> pojo) { 565 propertyMap = getChangedProperties(pojo, connection); 566 } else { 567 propertyMap = getTableColumnsPropertyInfo(objectClass, connection); 568 } 569 570 String updateStatement = buildUpdateString(object, propertyMap.keySet().stream().filter(col -> !columns.get(col).readOnly).toList().iterator(), connection); 571 572 if (object instanceof Persistable<?>) { 573 String key = propertyMap.keySet().toString(); 574 if (variableUpdateStatements.containsKey(objectClass) && variableUpdateStatements.get(objectClass).containsKey(key)) { 575 return variableUpdateStatements.get(objectClass).get(key); 576 } 577 578 variableUpdateStatements.putIfAbsent(objectClass, new HashMap<>()); 579 variableUpdateStatements.get(objectClass).put(key, updateStatement); 580 } else { 581 updateStatementsMap.put(objectClass, updateStatement); 582 } 583 584 585 if (log.isDebugEnabled()) { 586 log.debug("determineUpdateStatement for %s is %s", objectClass, updateStatement); 587 } 588 589 return updateStatement; 590 } 591 592 String getInsertStatement(Object object, Connection connection) throws PersismException { 593 String sql; 594 String key = getColumnsForInsert(object, connection).stream().map(columnInfo -> columnInfo.columnName).toList().toString(); 595 Class<?> objectClass = object.getClass(); 596 if (insertStatements.containsKey(objectClass) && insertStatements.get(objectClass).containsKey(key)) { 597 sql = insertStatements.get(objectClass).get(key); 598 } else { 599 sql = determineInsertStatement(object, connection); 600 } 601 if (log.isDebugEnabled()) { 602 log.debug("getInsertStatement for: %s %s", objectClass, sql); 603 } 604 return sql; 605 } 606 607 private synchronized String determineInsertStatement(Object object, Connection connection) { 608 List<ColumnInfo> columnsForInsert = getColumnsForInsert(object, connection); 609 String key = columnsForInsert.stream().map(columnInfo -> columnInfo.columnName).toList().toString(); 610 Class<?> objectClass = object.getClass(); 611 612 if (insertStatements.containsKey(objectClass) && insertStatements.get(objectClass).containsKey(key)) { 613 return insertStatements.get(objectClass).get(key); 614 } 615 616 String sd = connectionType.getKeywordStartDelimiter(); 617 String ed = connectionType.getKeywordEndDelimiter(); 618 619 TableInfo tableInfo = getTableInfo(objectClass); 620 String tableName = tableInfo.name(); 621 String schemaName = tableInfo.schema(); 622 623 StringBuilder sbi = new StringBuilder(); 624 sbi.append("INSERT INTO "); 625 if (isNotEmpty(schemaName)) { 626 sbi.append(sd).append(schemaName).append(ed).append("."); 627 } 628 sbi.append(sd).append(tableName).append(ed).append(" ("); 629 630 StringBuilder sbp = new StringBuilder(); 631 sbp.append(" VALUES ("); 632 633 String sep = ""; 634 635 for (ColumnInfo column : columnsForInsert) { 636 sbi.append(sep).append(sd).append(column.columnName).append(ed); 637 sbp.append(sep).append("?"); 638 sep = ", "; 639 } 640 641 if (connectionType == ConnectionType.MSSQL) { 642 // check if we have a sequence or other non auto-inc primary and use OUTPUT inserted.[COL] to get the value back. 643 Optional<ColumnInfo> primary = getPrimaryNonAutoIncColumn(object, connection); 644 if (primary.isPresent()) { 645 ColumnInfo col = primary.get(); 646 sbi.append(") ").append("OUTPUT inserted.").append(sd).append(col.columnName).append(ed).append(sbp).append(") "); 647 } else { 648 sbi.append(") ").append(sbp).append(") "); 649 } 650 } else { 651 sbi.append(") ").append(sbp).append(") "); 652 } 653 654 String insertStatement; 655 insertStatement = sbi.toString(); 656 657 if (log.isDebugEnabled()) { 658 log.debug("determineInsertStatement for %s is %s", object.getClass(), insertStatement); 659 } 660 661 insertStatements.putIfAbsent(objectClass, new HashMap<>()); 662 insertStatements.get(objectClass).put(key, insertStatement); 663 664 return insertStatement; 665 } 666 667 private List<ColumnInfo> getColumnsForInsert(Object object, Connection connection) { 668 Map<String, ColumnInfo> columns = getColumns(object.getClass(), connection); 669 Map<String, PropertyInfo> properties = getTableColumnsPropertyInfo(object.getClass(), connection); 670 return columns.values().stream(). 671 filter(col -> !col.autoIncrement). 672 filter(col -> !(col.hasDefault && properties.get(col.columnName).getValue(object) == null)). 673 filter(col -> !col.readOnly). 674 toList(); 675 } 676 677 private Optional<ColumnInfo> getPrimaryNonAutoIncColumn(Object object, Connection connection) { 678 Map<String, ColumnInfo> columns = getColumns(object.getClass(), connection); 679 Map<String, PropertyInfo> properties = getTableColumnsPropertyInfo(object.getClass(), connection); 680 return columns.values().stream(). 681 filter(col -> col.primary && col.hasDefault && properties.get(col.columnName).getValue(object) == null). 682 findFirst(); 683 } 684 685 String getDeleteStatement(Class<?> objectClass, Connection connection) { 686 if (deleteStatementsMap.containsKey(objectClass)) { 687 return deleteStatementsMap.get(objectClass); 688 } 689 return determineDeleteStatement(objectClass, connection); 690 } 691 692 String getDefaultDeleteStatement(Class<?> objectClass, Connection connection) { 693 return getDeleteStatement(objectClass, connection) + getWhereClause(objectClass, connection); 694 } 695 696 private synchronized String determineDeleteStatement(Class<?> objectClass, Connection connection) { 697 if (deleteStatementsMap.containsKey(objectClass)) { 698 return deleteStatementsMap.get(objectClass); 699 } 700 701 String sd = connectionType.getKeywordStartDelimiter(); 702 String ed = connectionType.getKeywordEndDelimiter(); 703 704 TableInfo tableInfo = getTableInfo(objectClass); 705 String tableName = tableInfo.name(); 706 String schemaName = tableInfo.schema(); 707 708 StringBuilder sb = new StringBuilder(); 709 sb.append("DELETE FROM "); 710 if (isNotEmpty(schemaName)) { 711 sb.append(sd).append(schemaName).append(ed).append("."); 712 } 713 sb.append(sd).append(tableName).append(ed); 714 String deleteStatement = sb.toString(); 715 716 if (log.isDebugEnabled()) { 717 log.debug("determineDeleteStatement for %s is %s", objectClass, deleteStatement); 718 } 719 720 deleteStatementsMap.put(objectClass, deleteStatement); 721 return deleteStatement; 722 } 723 724 String getPrimaryInClause(Class<?> objectClass, int paramCount, Connection connection) { 725 if (primaryInClauseMap.containsKey(objectClass) && primaryInClauseMap.get(objectClass).containsKey(paramCount)) { 726 return primaryInClauseMap.get(objectClass).get(paramCount); 727 } 728 return determinePrimaryInClause(objectClass, paramCount, connection); 729 } 730 731 private synchronized String determinePrimaryInClause(Class<?> objectClass, int paramCount, Connection connection) { 732 if (primaryInClauseMap.containsKey(objectClass) && primaryInClauseMap.get(objectClass).containsKey(paramCount)) { 733 return primaryInClauseMap.get(objectClass).get(paramCount); 734 } 735 736 Map<Integer, String> map = primaryInClauseMap.get(objectClass); 737 if (map == null) { 738 map = new HashMap<>(); 739 } 740 741 String sd = connectionType.getKeywordStartDelimiter(); 742 String ed = connectionType.getKeywordEndDelimiter(); 743 String andSep = ""; 744 745 String query = " WHERE "; 746 747 List<String> primaryKeys = getPrimaryKeys(objectClass, connection); 748 749 StringBuilder sb = new StringBuilder(query); 750 int groups = paramCount / primaryKeys.size(); 751 for (String column : primaryKeys) { 752 String sep = ""; 753 sb.append(andSep).append(sd).append(column).append(ed).append(" IN ("); 754 for (int j = 0; j < groups; j++) { 755 sb.append(sep).append("?"); 756 sep = ", "; 757 } 758 sb.append(")"); 759 andSep = " AND "; 760 } 761 query = sb.toString(); 762 763 map.put(paramCount, query); 764 primaryInClauseMap.put(objectClass, map); 765 766 return query; 767 } 768 769 770 String getWhereClause(Class<?> objectClass, Connection connection) { 771 if (primaryWhereClauseMap.containsKey(objectClass)) { 772 return primaryWhereClauseMap.get(objectClass); 773 } 774 return determineWhereClause(objectClass, connection); 775 } 776 777 private synchronized String determineWhereClause(Class<?> objectClass, Connection connection) { 778 if (primaryWhereClauseMap.containsKey(objectClass)) { 779 return primaryWhereClauseMap.get(objectClass); 780 } 781 782 String sep = ""; 783 784 StringBuilder sb = new StringBuilder(); 785 String sd = connectionType.getKeywordStartDelimiter(); 786 String ed = connectionType.getKeywordEndDelimiter(); 787 788 List<String> primaryKeys = getPrimaryKeys(objectClass, connection); 789 if (primaryKeys.size() == 0) { 790 throw new PersismException(Message.TableHasNoPrimaryKeysForWhere.message(getTableInfo(objectClass).name())); 791 } 792 793 sb.append(" WHERE "); 794 795 sep = ""; 796 for (String column : primaryKeys) { 797 sb.append(sep).append(sd).append(column).append(ed).append(" = ?"); 798 sep = " AND "; 799 } 800 801 String where = sb.toString(); 802 if (log.isDebugEnabled()) { 803 log.debug("determineWhereClause: %s %s", objectClass.getName(), where); 804 } 805 primaryWhereClauseMap.put(objectClass, where); 806 return where; 807 } 808 809 /* 810 * Default SELECT including WHERE Primary Keys - should only be called for tables 811 */ 812 String getDefaultSelectStatement(Class<?> objectClass, Connection connection) { 813 assert objectClass.getAnnotation(View.class) == null; 814 assert objectClass.getAnnotation(NotTable.class) == null; 815 816 return getSelectStatement(objectClass, connection) + getWhereClause(objectClass, connection); 817 } 818 819 /** 820 * SQL SELECT COLUMNS ONLY - make public? or put a delegate somewhere else? 821 * 822 * @param objectClass 823 * @param connection 824 * @return 825 */ 826 String getSelectStatement(Class<?> objectClass, Connection connection) { 827 if (selectStatementsMap.containsKey(objectClass)) { 828 return selectStatementsMap.get(objectClass); 829 } 830 return determineSelectStatement(objectClass, connection); 831 } 832 833 private synchronized String determineSelectStatement(Class<?> objectClass, Connection connection) { 834 835 if (selectStatementsMap.containsKey(objectClass)) { 836 return selectStatementsMap.get(objectClass); 837 } 838 839 String sd = connectionType.getKeywordStartDelimiter(); 840 String ed = connectionType.getKeywordEndDelimiter(); 841 842 TableInfo tableInfo = getTableInfo(objectClass); 843 String tableName = tableInfo.name(); 844 String schemaName = tableInfo.schema(); 845 846 StringBuilder sb = new StringBuilder(); 847 sb.append("SELECT "); 848 849 String sep = ""; 850 851 Map<String, ColumnInfo> columns = getColumns(objectClass, connection); 852 for (String column : columns.keySet()) { 853 ColumnInfo columnInfo = columns.get(column); 854 sb.append(sep).append(sd).append(columnInfo.columnName).append(ed); 855 sep = ", "; 856 } 857 sb.append(" FROM "); 858 if (isNotEmpty(schemaName)) { 859 sb.append(sd).append(schemaName).append(ed).append('.'); 860 } 861 sb.append(sd).append(tableName).append(ed); 862 863 864 String selectStatement = sb.toString(); 865 866 if (log.isDebugEnabled()) { 867 log.debug("determineSelectStatement for %s is %s", objectClass, selectStatement); 868 } 869 870 selectStatementsMap.put(objectClass, selectStatement); 871 872 return selectStatement; 873 } 874 875 private String buildUpdateString(Object object, Iterator<String> it, Connection connection) throws PersismException { 876 String sd = connectionType.getKeywordStartDelimiter(); 877 String ed = connectionType.getKeywordEndDelimiter(); 878 879 TableInfo tableInfo = getTableInfo(object.getClass()); 880 String tableName = tableInfo.name(); 881 String schemaName = tableInfo.schema(); 882 883 List<String> primaryKeys = getPrimaryKeys(object.getClass(), connection); 884 885 StringBuilder sb = new StringBuilder(); 886 sb.append("UPDATE "); 887 if (isNotEmpty(schemaName)) { 888 sb.append(sd).append(schemaName).append(ed).append("."); 889 } 890 sb.append(sd).append(tableName).append(ed).append(" SET "); 891 String sep = ""; 892 893 Map<String, ColumnInfo> columns = getColumns(object.getClass(), connection); 894 while (it.hasNext()) { 895 String column = it.next(); 896 ColumnInfo columnInfo = columns.get(column); 897 if (columnInfo.autoIncrement || columnInfo.primary) { 898 log.debug("buildUpdateString: skipping " + column); 899 } else { 900 sb.append(sep).append(sd).append(column).append(ed).append(" = ?"); 901 sep = ", "; 902 } 903 } 904 sb.append(" WHERE "); 905 sep = ""; 906 for (String column : primaryKeys) { 907 sb.append(sep).append(sd).append(column).append(ed).append(" = ?"); 908 sep = " AND "; 909 } 910 return sb.toString(); 911 } 912 913 Map<String, PropertyInfo> getChangedProperties(Persistable<?> persistable, Connection connection) throws PersismException { 914 915 Persistable<?> original = (Persistable<?>) persistable.readOriginalValue(); 916 917 Map<String, PropertyInfo> columns = getTableColumnsPropertyInfo(persistable.getClass(), connection); 918 919 if (original == null) { 920 // Could happen in the case of cloning or other operation - so it's never read, so it never sets original. 921 return columns; 922 } else { 923 Map<String, PropertyInfo> changedColumns = new LinkedHashMap<>(columns.keySet().size()); 924 925 for (String column : columns.keySet()) { 926 PropertyInfo propertyInfo = columns.get(column); 927 928 Object newValue = propertyInfo.getValue(persistable); 929 Object orgValue = propertyInfo.getValue(original); 930 if (!Objects.equals(newValue, orgValue)) { 931 changedColumns.put(column, propertyInfo); 932 } 933 } 934 return changedColumns; 935 } 936 937 } 938 939 <T> Map<String, ColumnInfo> getColumns(Class<T> objectClass, Connection connection) throws PersismException { 940 // Realistically at this point this objectClass will always be in the map since it's defined early 941 // when we get the table name, but I'll double-check it for determineColumnInfo anyway. 942 if (columnInfoMap.containsKey(objectClass)) { 943 return columnInfoMap.get(objectClass); 944 } 945 return determineColumnInfo(objectClass, getTableInfo(objectClass), connection); 946 } 947 948 <T> Map<String, PropertyInfo> getQueryColumnsPropertyInfo(Class<T> objectClass, ResultSet rs) throws PersismException { 949 try { 950 return determinePropertyInfoFromResultSet(objectClass, rs); 951 } catch (SQLException e) { 952 throw new PersismException(e.getMessage(), e); 953 } 954 } 955 956 <T> Map<String, PropertyInfo> getTableColumnsPropertyInfo(Class<T> objectClass, Connection connection) throws PersismException { 957 if (propertyInfoMap.containsKey(objectClass)) { 958 return propertyInfoMap.get(objectClass); 959 } 960 return determinePropertyInfo(objectClass, getTableInfo(objectClass), connection); 961 } 962 963 <T> TableInfo getTableInfo(Class<T> objectClass) { 964 if (tableOrViewMap.containsKey(objectClass)) { 965 return tableOrViewMap.get(objectClass); 966 } 967 968 return determineTableInfo(objectClass); 969 } 970 971 private synchronized <T> TableInfo determineTableInfo(Class<T> objectClass) { 972 if (tableOrViewMap.containsKey(objectClass)) { 973 return tableOrViewMap.get(objectClass); 974 } 975 976 String tableName; 977 String schemaName = null; 978 TableInfo foundInfo = null; 979 980 Table tableAnnotation = objectClass.getAnnotation(Table.class); 981 View viewAnnotation = objectClass.getAnnotation(View.class); 982 983 if (tableAnnotation != null) { 984 tableName = tableAnnotation.value(); 985 if (tableName.contains(".")) { 986 schemaName = tableName.substring(0, tableName.indexOf(".")); 987 tableName = tableName.substring(tableName.indexOf(".") + 1); 988 } 989 990 boolean found = false; 991 for (TableInfo table : tables) { 992 if (table.name().equalsIgnoreCase(tableName)) { 993 if (schemaName != null) { 994 if (table.schema().equalsIgnoreCase(schemaName)) { 995 foundInfo = table; 996 found = true; 997 break; 998 } 999 } else { 1000 foundInfo = table; 1001 found = true; 1002 break; 1003 } 1004 } 1005 } 1006 if (schemaName == null) { 1007 final String table = tableName; 1008 if (tables.stream().filter(tableInfo -> tableInfo.name().equalsIgnoreCase(table)).count() > 1) { 1009 throw new PersismException(Message.MoreThanOneTableOrViewInDifferentSchemas.message("TABLE", table)); 1010 } 1011 } 1012 if (!found) { 1013 throw new PersismException(Message.CouldNotFindTableNameInTheDatabase.message(tableName, objectClass.getName())); 1014 } 1015 } else if (viewAnnotation != null && isNotEmpty(viewAnnotation.value())) { 1016 tableName = viewAnnotation.value(); 1017 if (tableName.contains(".")) { 1018 schemaName = tableName.substring(0, tableName.indexOf(".")); 1019 tableName = tableName.substring(tableName.indexOf(".") + 1); 1020 } 1021 1022 boolean found = false; 1023 for (TableInfo view : views) { 1024 if (view.name().equalsIgnoreCase(tableName)) { 1025 if (schemaName != null) { 1026 if (view.schema().equalsIgnoreCase(schemaName)) { 1027 foundInfo = view; 1028 found = true; 1029 break; 1030 } 1031 } else { 1032 foundInfo = view; 1033 found = true; 1034 break; 1035 } 1036 } 1037 } 1038 if (schemaName == null) { 1039 final String table = tableName; 1040 if (views.stream().filter(tableInfo -> tableInfo.name().equalsIgnoreCase(table)).count() > 1) { 1041 throw new PersismException(Message.MoreThanOneTableOrViewInDifferentSchemas.message("VIEW", table)); 1042 } 1043 } 1044 if (!found) { 1045 throw new PersismException(Message.CouldNotFindViewNameInTheDatabase.message(tableName, objectClass.getName())); 1046 } 1047 } else { 1048 foundInfo = guessTableOrView(objectClass); 1049 } 1050 tableOrViewMap.put(objectClass, foundInfo); 1051 return foundInfo; 1052 } 1053 1054 // Returns the table/view name found in the DB in the same case as in the DB. 1055 // throws PersismException if we cannot guess any table/view name for this class. 1056 private <T> TableInfo guessTableOrView(Class<T> objectClass) throws PersismException { 1057 Set<String> guesses = new LinkedHashSet<>(6); // guess order is important 1058 List<TableInfo> guessedTables = new ArrayList<>(6); 1059 1060 String className = objectClass.getSimpleName(); 1061 1062 Set<TableInfo> list; 1063 boolean isView = false; 1064 if (objectClass.getAnnotation(View.class) != null) { 1065 list = views; 1066 isView = true; 1067 } else { 1068 list = tables; 1069 } 1070 1071 addTableGuesses(className, guesses); 1072 for (TableInfo table : list) { 1073 for (String guess : guesses) { 1074 if (guess.equalsIgnoreCase(table.name())) { 1075 guessedTables.add(table); 1076 } 1077 } 1078 } 1079 if (guessedTables.size() == 0) { 1080 throw new PersismException(Message.CouldNotDetermineTableOrViewForType.message(isView ? "view" : "table", objectClass.getName(), guesses)); 1081 } 1082 1083 if (guessedTables.size() > 1) { 1084 Set<String> multipleGuesses = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); 1085 multipleGuesses.addAll(guessedTables.stream().map(TableInfo::name).toList()); 1086 throw new PersismException(Message.CouldNotDetermineTableOrViewForTypeMultipleMatches.message(isView ? "view" : "table", objectClass.getName(), guesses, multipleGuesses)); 1087 } 1088 return guessedTables.get(0); 1089 } 1090 1091 private void addTableGuesses(String className, Collection<String> guesses) { 1092 // PascalCasing class name should make 1093 // PascalCasing 1094 // PascalCasings 1095 // Pascal Casing 1096 // Pascal Casings 1097 // Pascal_Casing 1098 // Pascal_Casings 1099 // Order is important. 1100 1101 String guess; 1102 String pluralClassName; 1103 String pluralClassName2 = null; 1104 1105 if (className.endsWith("y")) { 1106 // supply - supplies, category - categories 1107 pluralClassName = className.substring(0, className.length() - 1) + "ies"; 1108 pluralClassName2 = className + "s"; // holiday 1109 } else if (className.endsWith("x")) { 1110 // tax - taxes, mailbox - mailboxes 1111 pluralClassName = className + "es"; 1112 } else { 1113 pluralClassName = className + "s"; 1114 } 1115 1116 guesses.add(className); 1117 guesses.add(pluralClassName); 1118 if (pluralClassName2 != null) { 1119 guesses.add(pluralClassName2); 1120 } 1121 1122 guess = camelToTitleCase(className); 1123 guesses.add(guess); // name with spaces 1124 guesses.add(guess.replaceAll(" ", "_")); // name with spaces changed to _ 1125 1126 guess = camelToTitleCase(pluralClassName); 1127 guesses.add(guess); // plural name with spaces 1128 guesses.add(guess.replaceAll(" ", "_")); // plural name with spaces changed to _ 1129 1130 if (pluralClassName2 != null) { 1131 guess = camelToTitleCase(pluralClassName2); 1132 guesses.add(guess); // plural name with spaces 1133 guesses.add(guess.replaceAll(" ", "_")); // plural name with spaces changed to _ 1134 } 1135 } 1136 1137 List<String> getPrimaryKeys(Class<?> objectClass, Connection connection) throws PersismException { 1138 // todo cache? called by Session and SessionHelper. 1139 // ensures meta-data will be available because this method could be called before getting the table info object 1140 TableInfo tableInfo = getTableInfo(objectClass); 1141 1142 List<String> primaryKeys = new ArrayList<>(4); 1143 Map<String, ColumnInfo> map = getColumns(objectClass, connection); 1144 for (ColumnInfo col : map.values()) { 1145 if (col.primary) { 1146 primaryKeys.add(col.columnName); 1147 } 1148 } 1149 if (log.isDebugEnabled()) { 1150 log.debug("getPrimaryKeys for %s %s", tableInfo.name(), primaryKeys); 1151 } 1152 return primaryKeys; 1153 } 1154 1155 ConnectionType getConnectionType() { 1156 return connectionType; 1157 } 1158 1159 }