Coverage Summary for Class: MetaData (net.sf.persism)

Class Class, % Method, % Branch, % Line, %
MetaData 100% (1/1) 100% (37/37) 90.2% (321/356) 97.2% (559/575)


 package net.sf.persism;
 
 import net.sf.persism.annotations.*;
 
 import java.lang.annotation.Annotation;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
 import java.sql.*;
 import java.text.MessageFormat;
 import java.util.*;
 import java.util.concurrent.ConcurrentHashMap;
 
 import static net.sf.persism.Util.*;
 
 /**
  * DB and POJO related Metadata collected based connection url
  *
  * @author Dan Howard
  * @since 3/31/12 4:19 PM
  */
 final class MetaData {
 
     private static final Log log = Log.getLogger(MetaData.class);
 
     // properties for each class - static because this won't need to change between MetaData instances (collection is unmodifiable)
     private static final Map<Class<?>, Collection<PropertyInfo>> propertyMap = new ConcurrentHashMap<>(32);
 
     // column to property map for each class
     private final Map<Class<?>, Map<String, PropertyInfo>> propertyInfoMap = new ConcurrentHashMap<>(32);
     private final Map<Class<?>, Map<String, ColumnInfo>> columnInfoMap = new ConcurrentHashMap<>(32);
 
     // SQL for updates/inserts/deletes/selects for each class
     private final Map<Class<?>, String> updateStatementsMap = new ConcurrentHashMap<>(32);
     private final Map<Class<?>, String> deleteStatementsMap = new ConcurrentHashMap<>(32);
     private final Map<Class<?>, String> selectStatementsMap = new ConcurrentHashMap<>(32);
 
     // key - class, value - map key: columns to include, value: associated INSERT statement - this handles defaults on columns which may not need to be specified
     private final Map<Class<?>, Map<String, String>> insertStatements = new ConcurrentHashMap<>(32);
 
     // key - class, value - map key: changed columns, value: associated UPDATE statement
     private final Map<Class<?>, Map<String, String>> variableUpdateStatements = new ConcurrentHashMap<>(32);
 
     // Where clauses for primary key queries for tables
     private final Map<Class<?>, String> primaryWhereClauseMap = new ConcurrentHashMap<>(32);
 
     // Where ID IN (?, ?, ?) kinds of queries when no SQL is used.
     private final Map<Class<?>, Map<Integer, String>> primaryInClauseMap = new ConcurrentHashMap<>(32);
 
     // SQL parsed from SQL.where() - key is WHERE, value is parsed where clause
     Map<Class<?>, Map<String, String>> whereClauses = new ConcurrentHashMap<>(32);
 
     // WHERE clauses defined by JOIN operations (maintained by SessionHelper)
     Map<JoinInfo, Map<String, String>> childWhereClauses = new ConcurrentHashMap<>(32);
 
     // table/view for each class
     private final Map<Class<?>, TableInfo> tableOrViewMap = new ConcurrentHashMap<>(32);
 
     // list of tables in the DB
     private final Set<TableInfo> tables = new HashSet<>();
 
     // list of views in the DB
     private final Set<TableInfo> views = new HashSet<>();
 
     static final Map<String, MetaData> metaData = new ConcurrentHashMap<>(4);
 
     private final ConnectionType connectionType;
 
     final String sessionKey;
 
     // the "extra" characters that can be used in unquoted identifier names (those beyond a-z, A-Z, 0-9 and _)
     // Was using DatabaseMetaData getExtraNameCharacters() but some drivers don't provide these and still allow
     // for non-alphanumeric characters in column names. We'll just use a static set.
     private static final String EXTRA_NAME_CHARACTERS = "`~!@#$%^&*()-+=/|\\{}[]:;'\".,<>*";
     private static final String SELECT_FOR_COLUMNS = "SELECT * FROM {0}{1}{2} WHERE 1=0";
     private static final String SELECT_FOR_COLUMNS_WITH_SCHEMA = "SELECT * FROM {0}{1}{2}.{3}{4}{5} WHERE 1=0";
 
     private MetaData(Connection con, String sessionKey) throws SQLException {
 
         log.debug("MetaData CREATING instance [%s] ", sessionKey);
 
         connectionType = ConnectionType.get(sessionKey);
         this.sessionKey = sessionKey;
         if (connectionType == ConnectionType.Other) {
             log.warn(Message.UnknownConnectionType.message(con.getMetaData().getDatabaseProductName()));
         }
         populateTableList(con);
     }
 
     static synchronized MetaData getInstance(Connection con, String sessionKey) throws SQLException {
 
         if (sessionKey == null) {
             sessionKey = con.getMetaData().getURL();
         }
 
         if (metaData.get(sessionKey) == null) {
             metaData.put(sessionKey, new MetaData(con, sessionKey));
         }
         log.debug("MetaData getting instance %s", sessionKey);
         return metaData.get(sessionKey);
     }
 
     // Should only be called IF the map does not contain the column meta information yet.
     // Version for Tables
     private synchronized <T> Map<String, PropertyInfo> determinePropertyInfo(Class<T> objectClass, TableInfo table, Connection connection) {
         // double check map
         if (propertyInfoMap.containsKey(objectClass)) {
             return propertyInfoMap.get(objectClass);
         }
 
         // Not for @NotTable classes
         assert objectClass.getAnnotation(NotTable.class) == null;
 
         String sd = connectionType.getKeywordStartDelimiter();
         String ed = connectionType.getKeywordEndDelimiter();
 
         ResultSet rs = null;
         Statement st = null;
         try {
             st = connection.createStatement();
             // gives us real column names with case.
             String sql;
             if (isEmpty(table.schema())) {
                 sql = MessageFormat.format(SELECT_FOR_COLUMNS, sd, table.name(), ed);
             } else {
                 sql = MessageFormat.format(SELECT_FOR_COLUMNS_WITH_SCHEMA, sd, table.schema(), ed, sd, table.name(), ed);
             }
             if (log.isDebugEnabled()) {
                 log.debug("determineColumns: %s", sql);
             }
             rs = st.executeQuery(sql);
             Map<String, PropertyInfo> columns = determinePropertyInfoFromResultSet(objectClass, rs);
             propertyInfoMap.put(objectClass, columns);
             return columns;
         } catch (SQLException e) {
             throw new PersismException(e.getMessage(), e);
         } finally {
             cleanup(st, rs);
         }
     }
 
     private <T> Map<String, PropertyInfo> determinePropertyInfoFromResultSet(Class<T> objectClass, ResultSet rs) throws SQLException {
         ResultSetMetaData rsmd = rs.getMetaData();
         Collection<PropertyInfo> properties = getPropertyInfo(objectClass);
 
         int columnCount = rsmd.getColumnCount();
 
         Map<String, PropertyInfo> columns = new LinkedHashMap<>(columnCount);
         for (int j = 1; j <= columnCount; j++) {
             String realColumnName = rsmd.getColumnLabel(j);
             String columnName = realColumnName.toLowerCase().replace("_", "").replace(" ", "");
             // also replace these characters
             for (int x = 0; x < EXTRA_NAME_CHARACTERS.length(); x++) {
                 columnName = columnName.replace("" + EXTRA_NAME_CHARACTERS.charAt(x), "");
             }
             PropertyInfo foundProperty = null;
             for (PropertyInfo propertyInfo : properties) {
                 String checkName = propertyInfo.propertyName.toLowerCase().replace("_", "");
                 if (checkName.equalsIgnoreCase(columnName)) {
                     foundProperty = propertyInfo;
                     break;
                 } else {
                     // check annotation against column name
                     Column column = (Column) propertyInfo.getAnnotation(Column.class);
                     if (column != null) {
                         if (column.name().equalsIgnoreCase(realColumnName)) {
                             foundProperty = propertyInfo;
                             break;
                         }
                     }
                 }
             }
 
             if (foundProperty != null) {
                 columns.put(realColumnName, foundProperty);
             } else {
                 log.warn(Message.NoPropertyFoundForColumn.message(realColumnName, objectClass));
             }
         }
         return columns;
     }
 
     @SuppressWarnings({"JDBCExecuteWithNonConstantString", "SqlDialectInspection"})
     private synchronized <T> Map<String, ColumnInfo> determineColumnInfo(Class<T> objectClass, TableInfo table, Connection connection) {
         if (columnInfoMap.containsKey(objectClass)) {
             return columnInfoMap.get(objectClass);
         }
 
         Statement st = null;
         ResultSet rs = null;
         Map<String, PropertyInfo> properties = getTableColumnsPropertyInfo(objectClass, connection);
         String sd = connectionType.getKeywordStartDelimiter();
         String ed = connectionType.getKeywordEndDelimiter();
 
         String schemaName = table.schema();
         String tableName = table.name();
 
         try {
             st = connection.createStatement();
             String sql;
             if (isEmpty(schemaName)) {
                 sql = MessageFormat.format(SELECT_FOR_COLUMNS, sd, tableName, ed);
             } else {
                 sql = MessageFormat.format(SELECT_FOR_COLUMNS_WITH_SCHEMA, sd, schemaName, ed, sd, tableName, ed);
             }
             log.debug("determineColumnInfo %s", sql);
             rs = st.executeQuery(sql);
 
             // Make sure primary keys sorted by column order in case we have more than 1
             // then we'll know the order to apply the parameters.
             Map<String, ColumnInfo> map = new LinkedHashMap<>(32);
 
             boolean primaryKeysFound = false;
 
             // Grab all columns and make first pass to detect primary auto-inc
             ResultSetMetaData rsMetaData = rs.getMetaData();
             for (int i = 1; i <= rsMetaData.getColumnCount(); i++) {
                 // only include columns where we have a property
                 if (properties.containsKey(rsMetaData.getColumnLabel(i))) {
                     ColumnInfo columnInfo = new ColumnInfo();
                     columnInfo.columnName = rsMetaData.getColumnLabel(i);
                     columnInfo.autoIncrement = rsMetaData.isAutoIncrement(i);
                     columnInfo.primary = columnInfo.autoIncrement;
                     columnInfo.sqlColumnType = rsMetaData.getColumnType(i);
                     columnInfo.sqlColumnTypeName = rsMetaData.getColumnTypeName(i);
                     columnInfo.columnType = JavaType.convert(columnInfo.sqlColumnType);
                     columnInfo.length = rsMetaData.getColumnDisplaySize(i);
 
                     if (!primaryKeysFound) {
                         primaryKeysFound = columnInfo.primary;
                     }
 
                     PropertyInfo propertyInfo = properties.get(rsMetaData.getColumnLabel(i));
                     Annotation annotation = propertyInfo.getAnnotation(Column.class);
 
                     if (annotation != null) {
                         Column col = (Column) annotation;
                         if (col.hasDefault()) {
                             columnInfo.hasDefault = true;
                         }
 
                         if (col.primary()) {
                             columnInfo.primary = true;
                             if (!isDataClassATable(objectClass)) {
                                 log.warn(Message.PrimaryAnnotationOnViewOrQueryMakesNoSense.message(objectClass, propertyInfo.propertyName));
                             }
                         }
 
                         if (col.autoIncrement()) {
                             columnInfo.autoIncrement = true;
                             if (!columnInfo.columnType.isEligibleForAutoinc()) {
                                 // This will probably cause some error or other problem. Notify the user.
                                 log.warn(Message.ColumnAnnotatedAsAutoIncButNAN.message(columnInfo.columnName, columnInfo.columnType));
                             }
                         }
 
                         columnInfo.readOnly = col.readOnly();
 
                         if (!primaryKeysFound) {
                             primaryKeysFound = columnInfo.primary;
                         }
                     }
 
                     map.put(columnInfo.columnName, columnInfo);
                 }
             }
             rs.close();
 
             DatabaseMetaData dmd = connection.getMetaData();
 
             if (objectClass.getAnnotation(View.class) == null) {
 
                 if (isEmpty(schemaName)) {
                     rs = dmd.getPrimaryKeys(null, connectionType.getSchemaPattern(), tableName);
                 } else {
                     rs = dmd.getPrimaryKeys(null, schemaName, tableName);
                 }
                 int primaryKeysCount = 0;
                 while (rs.next()) {
                     ColumnInfo columnInfo = map.get(rs.getString("COLUMN_NAME"));
                     if (columnInfo != null) {
                         columnInfo.primary = true;
 
                         if (!primaryKeysFound) {
                             primaryKeysFound = columnInfo.primary;
                         }
                     }
                     primaryKeysCount++;
                 }
 
                 if (primaryKeysCount == 0 && !primaryKeysFound) {
                     log.warn(Message.DatabaseMetaDataCouldNotFindPrimaryKeys.message(table));
                 }
             }
 
             /*
              Get columns from database metadata since we don't get Type from resultSetMetaData
              with SQLite. + We also need to know if there's a default on a column.
              */
             if (isEmpty(schemaName)) {
                 rs = dmd.getColumns(null, connectionType.getSchemaPattern(), tableName, null);
             } else {
                 rs = dmd.getColumns(null, schemaName, tableName, null);
             }
             int columnsCount = 0;
             while (rs.next()) {
                 ColumnInfo columnInfo = map.get(rs.getString("COLUMN_NAME"));
                 if (columnInfo != null) {
                     if (!columnInfo.hasDefault) {
                         columnInfo.hasDefault = containsColumn(rs, "COLUMN_DEF") && rs.getString("COLUMN_DEF") != null;
                     }
 
                     // Do we not have autoinc info here? Yes.
                     // IS_AUTOINCREMENT = NO or YES
                     if (!columnInfo.autoIncrement) {
                         columnInfo.autoIncrement = containsColumn(rs, "IS_AUTOINCREMENT") && "YES".equalsIgnoreCase(rs.getString("IS_AUTOINCREMENT"));
                     }
 
                     // Re-assert the type since older version of SQLite could not detect types with empty resultsets
                     // It seems OK now in the newer JDBC driver.
                     // See testTypes unit test in TestSQLite
                     if (containsColumn(rs, "DATA_TYPE")) {
                         columnInfo.sqlColumnType = rs.getInt("DATA_TYPE");
                         if (containsColumn(rs, "TYPE_NAME")) {
                             columnInfo.sqlColumnTypeName = rs.getString("TYPE_NAME");
                         }
                         columnInfo.columnType = JavaType.convert(columnInfo.sqlColumnType);
                     }
                 }
                 columnsCount++;
             }
             rs.close();
 
             if (columnsCount == 0) {
                 // Shouldn't this be a fail? It would mean the user connecting to the DB
                 // has no permission to get the column meta-data
                 // It's a warning because it is possible to specify the column information
                 // with annotations rather than having Persism discover it.
                 log.warn(Message.DatabaseMetaDataCouldNotFindColumns.message(table));
             }
 
             // FOR Oracle which doesn't set autoinc in metadata even if we have:
             // "ID" NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY
             // Apparently that's not enough for the Oracle JDBC driver to indicate this is autoinc.
             // If we have a primary that's NUMERIC and HAS a default AND autoinc is not set then set it.
             if (connectionType == ConnectionType.Oracle) {
                 Optional<ColumnInfo> autoInc = map.values().stream().filter(e -> e.autoIncrement).findFirst();
                 if (autoInc.isEmpty()) {
                     // Do a second check if we have a primary that's numeric with a default.
                     Optional<ColumnInfo> primaryOpt = map.values().stream().filter(e -> e.primary).findFirst();
                     if (primaryOpt.isPresent()) {
                         ColumnInfo primary = primaryOpt.get();
                         if (primary.columnType.isEligibleForAutoinc() && primary.hasDefault) {
                             primary.autoIncrement = true;
                             primaryKeysFound = true;
                         }
                     }
                 }
             }
 
             if (!primaryKeysFound && isDataClassATable(objectClass)) {
                 // Should we fail-fast? Actually no, we should not fail here.
                 // It's very possible the user has a table that they will never
                 // update, delete or select (by primary).
                 // They may only want to do read operations with specified queries and in that
                 // context we don't need any primary keys. (same with insert)
                 log.warn(Message.NoPrimaryKeyFoundForTable.message(table));
             }
 
             columnInfoMap.put(objectClass, map);
             return map;
 
         } catch (SQLException e) {
             throw new PersismException(e.getMessage(), e);
         } finally {
             cleanup(st, rs);
         }
     }
 
     private static boolean isDataClassATable(Class<?> objectClass) {
         return objectClass.getAnnotation(Table.class) != null || (objectClass.getAnnotation(View.class) == null && objectClass.getAnnotation(NotTable.class) == null);
     }
 
     static <T> Collection<PropertyInfo> getPropertyInfo(Class<T> objectClass) {
         if (propertyMap.containsKey(objectClass)) {
             return propertyMap.get(objectClass);
         }
         return determinePropertyInfo(objectClass);
     }
 
     private static synchronized <T> Collection<PropertyInfo> determinePropertyInfo(Class<T> objectClass) {
         if (propertyMap.containsKey(objectClass)) {
             return propertyMap.get(objectClass);
         }
 
         Map<String, PropertyInfo> propertyInfos = new HashMap<>(32);
 
         List<Field> fields = new ArrayList<>(32);
 
         // getDeclaredFields does not get fields from super classes.....
         fields.addAll(Arrays.asList(objectClass.getDeclaredFields()));
         Class<?> sup = objectClass.getSuperclass();
         log.debug("fields for %s", sup);
         while (!sup.equals(Object.class) && !sup.equals(PersistableObject.class)) {
             fields.addAll(Arrays.asList(sup.getDeclaredFields()));
             sup = sup.getSuperclass();
             log.debug("fields for %s", sup);
         }
 
         Method[] methods = objectClass.getMethods();
 
         for (Field field : fields) {
             // Skip static fields
             if (Modifier.isStatic(field.getModifiers())) {
                 continue;
             }
 //            log.debug("Field Name: %s", field.getName());
             String propertyName = field.getName();
 //            log.debug("Property Name: *%s* ", propertyName);
 
             PropertyInfo propertyInfo = new PropertyInfo();
             propertyInfo.propertyName = propertyName;
             propertyInfo.field = field;
             Annotation[] annotations = field.getAnnotations();
             for (Annotation annotation : annotations) {
                 propertyInfo.annotations.put(annotation.annotationType(), annotation);
             }
 
             for (Method method : methods) {
                 String propertyNameToTest = field.getName().substring(0, 1).toUpperCase() + field.getName().substring(1);
                 // log.debug("property name for testing %s", propertyNameToTest);
                 if (propertyNameToTest.startsWith("Is") && propertyNameToTest.length() > 2 && Character.isUpperCase(propertyNameToTest.charAt(2))) {
                     propertyNameToTest = propertyName.substring(2);
                 }
 
                 String[] candidates = {"set" + propertyNameToTest, "get" + propertyNameToTest, "is" + propertyNameToTest, field.getName()};
 
                 if (Arrays.asList(candidates).contains(method.getName())) {
                     //log.debug("  METHOD: %s", method.getName());
 
                     annotations = method.getAnnotations();
                     for (Annotation annotation : annotations) {
                         propertyInfo.annotations.put(annotation.annotationType(), annotation);
                     }
 
                     // OR added to fix to builder pattern style when your setters are just the field name
                     if (method.getName().equalsIgnoreCase("set" + propertyNameToTest) || method.getParameterCount() > 0) {
                         propertyInfo.setter = method;
                     } else {
                         propertyInfo.getter = method;
                     }
                 }
             }
 
             propertyInfo.isJoin = propertyInfo.getAnnotation(Join.class) != null;
             propertyInfos.put(propertyName.toLowerCase(), propertyInfo);
         }
 
         // Remove any properties found with the NotColumn annotation
         // http://stackoverflow.com/questions/2026104/hashmap-keyset-foreach-and-remove
         Iterator<Map.Entry<String, PropertyInfo>> it = propertyInfos.entrySet().iterator();
         while (it.hasNext()) {
             Map.Entry<String, PropertyInfo> entry = it.next();
             PropertyInfo info = entry.getValue();
             // added support for transient
             if (info.getAnnotation(NotColumn.class) != null || Modifier.isTransient(info.field.getModifiers())) {
                 it.remove();
             }
         }
 
         Collection<PropertyInfo> properties = Collections.unmodifiableCollection(propertyInfos.values());
         propertyMap.put(objectClass, properties);
 
         // If a view or query - warn if we find any setters
         if (objectClass.getAnnotation(NotTable.class) != null || objectClass.getAnnotation(View.class) != null) {
             List<String> setters = new ArrayList<>();
             for (PropertyInfo propertyInfo : properties) {
                 if (propertyInfo.setter != null) {
                     setters.add(propertyInfo.propertyName);
                 }
             }
 
             if (setters.size() > 0) {
                 log.warn(Message.SettersFoundInReadOnlyObject.message(objectClass, setters));
             }
         }
 
         return properties;
     }
 
     private static final String TABLE = "TABLE";
     private static final String VIEW = "VIEW";
     private static final String[] tableTypes = {TABLE, VIEW};
 
     // Populates the tables list with table names from the DB.
     // This list is used for discovery of the table name from a class.
     // ONLY to be called from Init in a synchronized way.
     // NULL POINTER WITH
     // http://social.msdn.microsoft.com/Forums/en-US/sqldataaccess/thread/5c74094a-8506-4278-ac1c-f07d1bfdb266
     // solution:
     // http://stackoverflow.com/questions/8988945/java7-sqljdbc4-sql-error-08s01-on-getconnection
     void populateTableList(Connection con) throws PersismException {
         views.clear();
         tables.clear();
         try (ResultSet rs = con.getMetaData().getTables(null, connectionType.getSchemaPattern(), null, tableTypes)) {
             String name;
             while (rs.next()) {
                 name = rs.getString("TABLE_NAME");
                 if (VIEW.equalsIgnoreCase(rs.getString("TABLE_TYPE"))) {
                     views.add(new TableInfo(name, rs.getString("TABLE_SCHEM"), connectionType));
                 } else {
                     tables.add(new TableInfo(name, rs.getString("TABLE_SCHEM"), connectionType));
                 }
             }
         } catch (SQLException e) {
             throw new PersismException(e.getMessage(), e);
         }
     }
 
     /**
      * @param object
      * @param connection
      * @return sql update string
      * @throws NoChangesDetectedForUpdateException if the data object implements Persistable and there are no changes detected
      */
     String getUpdateStatement(Object object, Connection connection) throws PersismException, NoChangesDetectedForUpdateException {
 
         String sql;
         if (object instanceof Persistable<?> pojo) {
             Map<String, PropertyInfo> changes = getChangedProperties(pojo, connection);
             if (changes.size() == 0) {
                 throw new NoChangesDetectedForUpdateException();
             }
 
             Class<?> objectClass = object.getClass();
             String key = changes.keySet().toString();
             if (variableUpdateStatements.containsKey(objectClass) && variableUpdateStatements.get(objectClass).containsKey(key)) {
                 sql = variableUpdateStatements.get(objectClass).get(key);
             } else {
                 sql = determineUpdateStatement(pojo, connection);
             }
 
             if (log.isDebugEnabled()) {
                 log.debug("getUpdateStatement for %s for changed fields is %s", objectClass, sql);
             }
             return sql;
         }
 
         if (updateStatementsMap.containsKey(object.getClass())) {
             sql = updateStatementsMap.get(object.getClass());
         } else {
             sql = determineUpdateStatement(object, connection);
         }
         if (log.isDebugEnabled()) {
             log.debug("getUpdateStatement for: %s %s", object.getClass(), sql);
         }
         return sql;
     }
 
     // Used by Objects not implementing Persistable since they will always use the same update statement
     private synchronized String determineUpdateStatement(Object object, Connection connection) {
         if (updateStatementsMap.containsKey(object.getClass())) {
             return updateStatementsMap.get(object.getClass());
         }
 
         Class<?> objectClass = object.getClass();
         var columns = getColumns(objectClass, connection);
         Map<String, PropertyInfo> propertyMap;
         if (object instanceof Persistable<?> pojo) {
             propertyMap = getChangedProperties(pojo, connection);
         } else {
             propertyMap = getTableColumnsPropertyInfo(objectClass, connection);
         }
 
         String updateStatement = buildUpdateString(object, propertyMap.keySet().stream().filter(col -> !columns.get(col).readOnly).toList().iterator(), connection);
 
         if (object instanceof Persistable<?>) {
             String key = propertyMap.keySet().toString();
             if (variableUpdateStatements.containsKey(objectClass) && variableUpdateStatements.get(objectClass).containsKey(key)) {
                 return variableUpdateStatements.get(objectClass).get(key);
             }
 
             variableUpdateStatements.putIfAbsent(objectClass, new HashMap<>());
             variableUpdateStatements.get(objectClass).put(key, updateStatement);
         } else {
             updateStatementsMap.put(objectClass, updateStatement);
         }
 
 
         if (log.isDebugEnabled()) {
             log.debug("determineUpdateStatement for %s is %s", objectClass, updateStatement);
         }
 
         return updateStatement;
     }
 
     String getInsertStatement(Object object, Connection connection) throws PersismException {
         String sql;
         String key = getColumnsForInsert(object, connection).stream().map(columnInfo -> columnInfo.columnName).toList().toString();
         Class<?> objectClass = object.getClass();
         if (insertStatements.containsKey(objectClass) && insertStatements.get(objectClass).containsKey(key)) {
             sql = insertStatements.get(objectClass).get(key);
         } else {
             sql = determineInsertStatement(object, connection);
         }
         if (log.isDebugEnabled()) {
             log.debug("getInsertStatement for: %s %s", objectClass, sql);
         }
         return sql;
     }
 
     private synchronized String determineInsertStatement(Object object, Connection connection) {
         List<ColumnInfo> columnsForInsert = getColumnsForInsert(object, connection);
         String key = columnsForInsert.stream().map(columnInfo -> columnInfo.columnName).toList().toString();
         Class<?> objectClass = object.getClass();
 
         if (insertStatements.containsKey(objectClass) && insertStatements.get(objectClass).containsKey(key)) {
             return insertStatements.get(objectClass).get(key);
         }
 
         String sd = connectionType.getKeywordStartDelimiter();
         String ed = connectionType.getKeywordEndDelimiter();
 
         TableInfo tableInfo = getTableInfo(objectClass);
         String tableName = tableInfo.name();
         String schemaName = tableInfo.schema();
 
         StringBuilder sbi = new StringBuilder();
         sbi.append("INSERT INTO ");
         if (isNotEmpty(schemaName)) {
             sbi.append(sd).append(schemaName).append(ed).append(".");
         }
         sbi.append(sd).append(tableName).append(ed).append(" (");
 
         StringBuilder sbp = new StringBuilder();
         sbp.append(" VALUES (");
 
         String sep = "";
 
         for (ColumnInfo column : columnsForInsert) {
             sbi.append(sep).append(sd).append(column.columnName).append(ed);
             sbp.append(sep).append("?");
             sep = ", ";
         }
 
         if (connectionType == ConnectionType.MSSQL) {
             // check if we have a sequence or other non auto-inc primary and use OUTPUT inserted.[COL] to get the value back.
             // TODO WE COULD PROBABLY BE OK JUST ALWAYS using OUTPUT.inserted.* - but try it. CRASHES stuff. :(
 //            sbi.append("OUTPUT inserted.* ");
             Optional<ColumnInfo> primary = getPrimaryNonAutoIncColumn(object, connection);
             if (primary.isPresent()) {
                 ColumnInfo col = primary.get();
                 sbi.append(") ").append("OUTPUT inserted.").append(sd).append(col.columnName).append(ed).append(sbp).append(") ");
             } else {
                 sbi.append(") ").append(sbp).append(") ");
             }
         } else {
             sbi.append(") ").append(sbp).append(") ");
         }
 
         String insertStatement;
         if (connectionType == ConnectionType.SQLite) {
             sbi.append("RETURNING *");
         }
         insertStatement = sbi.toString();
 
         if (log.isDebugEnabled()) {
             log.debug("determineInsertStatement for %s is %s", object.getClass(), insertStatement);
         }
 
         insertStatements.putIfAbsent(objectClass, new HashMap<>());
         insertStatements.get(objectClass).put(key, insertStatement);
 
         return insertStatement;
     }
 
     private List<ColumnInfo> getColumnsForInsert(Object object, Connection connection) {
         Map<String, ColumnInfo> columns = getColumns(object.getClass(), connection);
         Map<String, PropertyInfo> properties = getTableColumnsPropertyInfo(object.getClass(), connection);
         return columns.values().stream().
                 filter(col -> !col.autoIncrement).
                 filter(col -> !(col.hasDefault && properties.get(col.columnName).getValue(object) == null)).
                 filter(col -> !col.readOnly).
                 toList();
     }
 
     private Optional<ColumnInfo> getPrimaryNonAutoIncColumn(Object object, Connection connection) {
         Map<String, ColumnInfo> columns = getColumns(object.getClass(), connection);
         Map<String, PropertyInfo> properties = getTableColumnsPropertyInfo(object.getClass(), connection);
         return columns.values().stream().
                 filter(col -> col.primary && col.hasDefault && properties.get(col.columnName).getValue(object) == null).
                 findFirst();
     }
 
     String getDeleteStatement(Class<?> objectClass, Connection connection) {
         if (deleteStatementsMap.containsKey(objectClass)) {
             return deleteStatementsMap.get(objectClass);
         }
         return determineDeleteStatement(objectClass, connection);
     }
 
     String getDefaultDeleteStatement(Class<?> objectClass, Connection connection) {
         return getDeleteStatement(objectClass, connection) + " WHERE " + getWhereClause(objectClass, connection);
     }
 
     private synchronized String determineDeleteStatement(Class<?> objectClass, Connection connection) {
         if (deleteStatementsMap.containsKey(objectClass)) {
             return deleteStatementsMap.get(objectClass);
         }
 
         String sd = connectionType.getKeywordStartDelimiter();
         String ed = connectionType.getKeywordEndDelimiter();
 
         TableInfo tableInfo = getTableInfo(objectClass);
         String tableName = tableInfo.name();
         String schemaName = tableInfo.schema();
 
         StringBuilder sb = new StringBuilder();
         sb.append("DELETE FROM ");
         if (isNotEmpty(schemaName)) {
             sb.append(sd).append(schemaName).append(ed).append(".");
         }
         sb.append(sd).append(tableName).append(ed);
         String deleteStatement = sb.toString();
 
         if (log.isDebugEnabled()) {
             log.debug("determineDeleteStatement for %s is %s", objectClass, deleteStatement);
         }
 
         deleteStatementsMap.put(objectClass, deleteStatement);
         return deleteStatement;
     }
 
     String getPrimaryInClause(Class<?> objectClass, int paramCount, Connection connection) {
         if (primaryInClauseMap.containsKey(objectClass) && primaryInClauseMap.get(objectClass).containsKey(paramCount)) {
             return primaryInClauseMap.get(objectClass).get(paramCount);
         }
         return determinePrimaryInClause(objectClass, paramCount, connection);
     }
 
     private synchronized String determinePrimaryInClause(Class<?> objectClass, int paramCount, Connection connection) {
         if (primaryInClauseMap.containsKey(objectClass) && primaryInClauseMap.get(objectClass).containsKey(paramCount)) {
             return primaryInClauseMap.get(objectClass).get(paramCount);
         }
 
         Map<Integer, String> map = primaryInClauseMap.get(objectClass);
         if (map == null) {
             map = new HashMap<>();
         }
 
         String sd = connectionType.getKeywordStartDelimiter();
         String ed = connectionType.getKeywordEndDelimiter();
         String andSep = "";
 
         String query = "";
 
         List<String> primaryKeys = getPrimaryKeys(objectClass, connection);
 
         StringBuilder sb = new StringBuilder(query);
         int groups = paramCount / primaryKeys.size();
         for (String column : primaryKeys) {
             String sep = "";
             sb.append(andSep).append(sd).append(column).append(ed).append(" IN (");
             for (int j = 0; j < groups; j++) {
                 sb.append(sep).append("?");
                 sep = ", ";
             }
             sb.append(")");
             andSep = " AND ";
         }
         query = sb.toString();
 
         map.put(paramCount, query);
         primaryInClauseMap.put(objectClass, map);
 
         return query;
     }
 
 
     String getWhereClause(Class<?> objectClass, Connection connection) {
         if (primaryWhereClauseMap.containsKey(objectClass)) {
             return primaryWhereClauseMap.get(objectClass);
         }
         return determineWhereClause(objectClass, connection);
     }
 
     private synchronized String determineWhereClause(Class<?> objectClass, Connection connection) {
         if (primaryWhereClauseMap.containsKey(objectClass)) {
             return primaryWhereClauseMap.get(objectClass);
         }
 
         String sep = "";
 
         StringBuilder sb = new StringBuilder();
         String sd = connectionType.getKeywordStartDelimiter();
         String ed = connectionType.getKeywordEndDelimiter();
 
         List<String> primaryKeys = getPrimaryKeys(objectClass, connection);
         if (primaryKeys.size() == 0) {
             throw new PersismException(Message.TableHasNoPrimaryKeysForWhere.message(getTableInfo(objectClass).name()));
         }
 
         //sb.append(" WHERE ");
 
         sep = "";
         for (String column : primaryKeys) {
             sb.append(sep).append(sd).append(column).append(ed).append(" = ?");
             sep = " AND ";
         }
 
         String where = sb.toString();
         if (log.isDebugEnabled()) {
             log.debug("determineWhereClause: %s %s", objectClass.getName(), where);
         }
         primaryWhereClauseMap.put(objectClass, where);
         return where;
     }
 
     /*
      * Default SELECT including WHERE Primary Keys - should only be called for tables
      */
     String getDefaultSelectStatement(Class<?> objectClass, Connection connection) {
         assert objectClass.getAnnotation(View.class) == null;
         assert objectClass.getAnnotation(NotTable.class) == null;
 
         return getSelectStatement(objectClass, connection) + " WHERE " + getWhereClause(objectClass, connection);
     }
 
     /**
      * SQL SELECT COLUMNS ONLY - make public? or put a delegate somewhere else?
      *
      * @param objectClass
      * @param connection
      * @return
      */
     String getSelectStatement(Class<?> objectClass, Connection connection) {
         if (selectStatementsMap.containsKey(objectClass)) {
             return selectStatementsMap.get(objectClass);
         }
         return determineSelectStatement(objectClass, connection);
     }
 
     private synchronized String determineSelectStatement(Class<?> objectClass, Connection connection) {
 
         if (selectStatementsMap.containsKey(objectClass)) {
             return selectStatementsMap.get(objectClass);
         }
 
         String sd = connectionType.getKeywordStartDelimiter();
         String ed = connectionType.getKeywordEndDelimiter();
 
         TableInfo tableInfo = getTableInfo(objectClass);
         String tableName = tableInfo.name();
         String schemaName = tableInfo.schema();
 
         StringBuilder sb = new StringBuilder();
         sb.append("SELECT ");
 
         String sep = "";
 
         Map<String, ColumnInfo> columns = getColumns(objectClass, connection);
         for (String column : columns.keySet()) {
             ColumnInfo columnInfo = columns.get(column);
             sb.append(sep).append(sd).append(columnInfo.columnName).append(ed);
             sep = ", ";
         }
         sb.append(" FROM ");
         if (isNotEmpty(schemaName)) {
             sb.append(sd).append(schemaName).append(ed).append('.');
         }
         sb.append(sd).append(tableName).append(ed);
 
 
         String selectStatement = sb.toString();
 
         if (log.isDebugEnabled()) {
             log.debug("determineSelectStatement for %s is %s", objectClass, selectStatement);
         }
 
         selectStatementsMap.put(objectClass, selectStatement);
 
         return selectStatement;
     }
 
     private String buildUpdateString(Object object, Iterator<String> it, Connection connection) throws PersismException {
         String sd = connectionType.getKeywordStartDelimiter();
         String ed = connectionType.getKeywordEndDelimiter();
 
         TableInfo tableInfo = getTableInfo(object.getClass());
         String tableName = tableInfo.name();
         String schemaName = tableInfo.schema();
 
         List<String> primaryKeys = getPrimaryKeys(object.getClass(), connection);
 
         StringBuilder sb = new StringBuilder();
         sb.append("UPDATE ");
         if (isNotEmpty(schemaName)) {
             sb.append(sd).append(schemaName).append(ed).append(".");
         }
         sb.append(sd).append(tableName).append(ed).append(" SET ");
         String sep = "";
 
         Map<String, ColumnInfo> columns = getColumns(object.getClass(), connection);
         while (it.hasNext()) {
             String column = it.next();
             ColumnInfo columnInfo = columns.get(column);
             if (columnInfo.autoIncrement || columnInfo.primary) {
                 log.debug("buildUpdateString: skipping " + column);
             } else {
                 sb.append(sep).append(sd).append(column).append(ed).append(" = ?");
                 sep = ", ";
             }
         }
         sb.append(" WHERE ");
         sep = "";
         for (String column : primaryKeys) {
             sb.append(sep).append(sd).append(column).append(ed).append(" = ?");
             sep = " AND ";
         }
         return sb.toString();
     }
 
     Map<String, PropertyInfo> getChangedProperties(Persistable<?> persistable, Connection connection) throws PersismException {
 
         Persistable<?> original = (Persistable<?>) persistable.readOriginalValue();
 
         Map<String, PropertyInfo> columns = getTableColumnsPropertyInfo(persistable.getClass(), connection);
 
         if (original == null) {
             // Could happen in the case of cloning or other operation - so it's never read, so it never sets original.
             return columns;
         } else {
             Map<String, PropertyInfo> changedColumns = new LinkedHashMap<>(columns.keySet().size());
 
             for (String column : columns.keySet()) {
                 PropertyInfo propertyInfo = columns.get(column);
 
                 Object newValue = propertyInfo.getValue(persistable);
                 Object orgValue = propertyInfo.getValue(original);
                 if (!Objects.equals(newValue, orgValue)) {
                     changedColumns.put(column, propertyInfo);
                 }
             }
             return changedColumns;
         }
 
     }
 
     <T> Map<String, ColumnInfo> getColumns(Class<T> objectClass, Connection connection) throws PersismException {
         // Realistically at this point this objectClass will always be in the map since it's defined early
         // when we get the table name, but I'll double-check it for determineColumnInfo anyway.
         if (columnInfoMap.containsKey(objectClass)) {
             return columnInfoMap.get(objectClass);
         }
         return determineColumnInfo(objectClass, getTableInfo(objectClass), connection);
     }
 
     <T> Map<String, PropertyInfo> getQueryColumnsPropertyInfo(Class<T> objectClass, ResultSet rs) throws PersismException {
         try {
             return determinePropertyInfoFromResultSet(objectClass, rs);
         } catch (SQLException e) {
             throw new PersismException(e.getMessage(), e);
         }
     }
 
     <T> Map<String, PropertyInfo> getTableColumnsPropertyInfo(Class<T> objectClass, Connection connection) throws PersismException {
         if (propertyInfoMap.containsKey(objectClass)) {
             return propertyInfoMap.get(objectClass);
         }
         return determinePropertyInfo(objectClass, getTableInfo(objectClass), connection);
     }
 
     <T> TableInfo getTableInfo(Class<T> objectClass) {
         if (tableOrViewMap.containsKey(objectClass)) {
             return tableOrViewMap.get(objectClass);
         }
 
         return determineTableInfo(objectClass);
     }
 
     private synchronized <T> TableInfo determineTableInfo(Class<T> objectClass) {
         if (tableOrViewMap.containsKey(objectClass)) {
             return tableOrViewMap.get(objectClass);
         }
 
         String tableName;
         String schemaName = null;
         TableInfo foundInfo = null;
 
         Table tableAnnotation = objectClass.getAnnotation(Table.class);
         View viewAnnotation = objectClass.getAnnotation(View.class);
 
         if (tableAnnotation != null) {
             tableName = tableAnnotation.value();
             if (tableName.contains(".")) {
                 schemaName = tableName.substring(0, tableName.indexOf("."));
                 tableName = tableName.substring(tableName.indexOf(".") + 1);
             }
 
             boolean found = false;
             for (TableInfo table : tables) {
                 if (table.name().equalsIgnoreCase(tableName)) {
                     if (schemaName != null) {
                         if (table.schema().equalsIgnoreCase(schemaName)) {
                             foundInfo = table;
                             found = true;
                             break;
                         }
                     } else {
                         foundInfo = table;
                         found = true;
                         break;
                     }
                 }
             }
             if (schemaName == null) {
                 final String table = tableName;
                 if (tables.stream().filter(tableInfo -> tableInfo.name().equalsIgnoreCase(table)).count() > 1) {
                     throw new PersismException(Message.MoreThanOneTableOrViewInDifferentSchemas.message("TABLE", table));
                 }
             }
             if (!found) {
                 throw new PersismException(Message.CouldNotFindTableNameInTheDatabase.message(tableName, objectClass.getName()));
             }
         } else if (viewAnnotation != null && isNotEmpty(viewAnnotation.value())) {
             tableName = viewAnnotation.value();
             if (tableName.contains(".")) {
                 schemaName = tableName.substring(0, tableName.indexOf("."));
                 tableName = tableName.substring(tableName.indexOf(".") + 1);
             }
 
             boolean found = false;
             for (TableInfo view : views) {
                 if (view.name().equalsIgnoreCase(tableName)) {
                     if (schemaName != null) {
                         if (view.schema().equalsIgnoreCase(schemaName)) {
                             foundInfo = view;
                             found = true;
                             break;
                         }
                     } else {
                         foundInfo = view;
                         found = true;
                         break;
                     }
                 }
             }
             if (schemaName == null) {
                 final String table = tableName;
                 if (views.stream().filter(tableInfo -> tableInfo.name().equalsIgnoreCase(table)).count() > 1) {
                     throw new PersismException(Message.MoreThanOneTableOrViewInDifferentSchemas.message("VIEW", table));
                 }
             }
             if (!found) {
                 throw new PersismException(Message.CouldNotFindViewNameInTheDatabase.message(tableName, objectClass.getName()));
             }
         } else {
             foundInfo = guessTableOrView(objectClass);
         }
         tableOrViewMap.put(objectClass, foundInfo);
         return foundInfo;
     }
 
     // Returns the table/view name found in the DB in the same case as in the DB.
     // throws PersismException if we cannot guess any table/view name for this class.
     private <T> TableInfo guessTableOrView(Class<T> objectClass) throws PersismException {
         Set<String> guesses = new LinkedHashSet<>(6); // guess order is important
         List<TableInfo> guessedTables = new ArrayList<>(6);
 
         String className = objectClass.getSimpleName();
 
         Set<TableInfo> list;
         boolean isView = false;
         if (objectClass.getAnnotation(View.class) != null) {
             list = views;
             isView = true;
         } else {
             list = tables;
         }
 
         addTableGuesses(className, guesses);
         for (TableInfo table : list) {
             for (String guess : guesses) {
                 if (guess.equalsIgnoreCase(table.name())) {
                     guessedTables.add(table);
                 }
             }
         }
         if (guessedTables.size() == 0) {
             throw new PersismException(Message.CouldNotDetermineTableOrViewForType.message(isView ? "view" : "table", objectClass.getName(), guesses));
         }
 
         if (guessedTables.size() > 1) {
             Set<String> multipleGuesses = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
             multipleGuesses.addAll(guessedTables.stream().map(TableInfo::name).toList());
             throw new PersismException(Message.CouldNotDetermineTableOrViewForTypeMultipleMatches.message(isView ? "view" : "table", objectClass.getName(), guesses, multipleGuesses));
         }
         return guessedTables.get(0);
     }
 
     private void addTableGuesses(String className, Collection<String> guesses) {
         // PascalCasing class name should make
         // PascalCasing
         // PascalCasings
         // Pascal Casing
         // Pascal Casings
         // Pascal_Casing
         // Pascal_Casings
         // Order is important.
 
         String guess;
         String pluralClassName;
         String pluralClassName2 = null;
 
         if (className.endsWith("y")) {
             // supply - supplies, category - categories
             pluralClassName = className.substring(0, className.length() - 1) + "ies";
             pluralClassName2 = className + "s"; // holiday
         } else if (className.endsWith("x")) {
             // tax - taxes, mailbox - mailboxes
             pluralClassName = className + "es";
         } else {
             pluralClassName = className + "s";
         }
 
         guesses.add(className);
         guesses.add(pluralClassName);
         if (pluralClassName2 != null) {
             guesses.add(pluralClassName2);
         }
 
         guess = camelToTitleCase(className);
         guesses.add(guess); // name with spaces
         guesses.add(guess.replaceAll(" ", "_")); // name with spaces changed to _
 
         guess = camelToTitleCase(pluralClassName);
         guesses.add(guess); // plural name with spaces
         guesses.add(guess.replaceAll(" ", "_")); // plural name with spaces changed to _
 
         if (pluralClassName2 != null) {
             guess = camelToTitleCase(pluralClassName2);
             guesses.add(guess); // plural name with spaces
             guesses.add(guess.replaceAll(" ", "_")); // plural name with spaces changed to _
         }
     }
 
     List<String> getPrimaryKeys(Class<?> objectClass, Connection connection) throws PersismException {
         // todo cache? called by Session and SessionHelper.
         // ensures meta-data will be available because this method could be called before getting the table info object
         TableInfo tableInfo = getTableInfo(objectClass);
 
         List<String> primaryKeys = new ArrayList<>(4);
         Map<String, ColumnInfo> map = getColumns(objectClass, connection);
         for (ColumnInfo col : map.values()) {
             if (col.primary) {
                 primaryKeys.add(col.columnName);
             }
         }
         if (log.isDebugEnabled()) {
             log.debug("getPrimaryKeys for %s %s", tableInfo.name(), primaryKeys);
         }
         return primaryKeys;
     }
 
     ConnectionType getConnectionType() {
         return connectionType;
     }
 
 }