/* * Copyright 2014 James Moger. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.iciql; import java.lang.annotation.Annotation; import java.lang.reflect.Array; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Proxy; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.iciql.Iciql.DataTypeAdapter; import com.iciql.util.JdbcUtils; import com.iciql.util.StringUtils; import com.iciql.util.Utils; /** * DaoProxy creates a dynamic instance of the provided Dao interface. * * @author James Moger * * @param */ final class DaoProxy implements InvocationHandler, Dao { private final Db db; private final Class daoInterface; private final char bindingDelimiter = ':'; private final Map indexedSqlCache; DaoProxy(Db db, Class daoInterface) { this.db = db; this.daoInterface = daoInterface; this.indexedSqlCache = new ConcurrentHashMap(); } /** * Builds a proxy object for the DAO interface. * * @return a proxy object */ @SuppressWarnings("unchecked") X build() { if (!daoInterface.isInterface()) { throw new IciqlException("Dao {0} must be an interface!", daoInterface.getName()); } ClassLoader classLoader = daoInterface.getClassLoader(); Set> interfaces = new HashSet>(); interfaces.add(Dao.class); interfaces.add(daoInterface); for (Class clazz : daoInterface.getInterfaces()) { interfaces.add(clazz); } Class[] constructorParams = { InvocationHandler.class }; Class[] allInterfaces = interfaces.toArray(new Class[interfaces.size()]); try { Class proxyClass = Proxy.getProxyClass(classLoader, allInterfaces); Constructor proxyConstructor = proxyClass.getConstructor(constructorParams); return (X) proxyConstructor.newInstance(new Object[] { this }); } catch (Exception e) { throw new IciqlException(e); } } /** * Invoke intercepts method calls and delegates execution to the appropriate object. */ @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { try { if (method.getDeclaringClass() == Dao.class) { return method.invoke(this, args); } else if (method.isAnnotationPresent(SqlQuery.class)) { String sql = method.getAnnotation(SqlQuery.class).value(); String statement = db.getDaoStatementProvider().getStatement(sql, db.getMode()); return executeQuery(method, args, statement); } else if (method.isAnnotationPresent(SqlStatement.class)) { String sql = method.getAnnotation(SqlStatement.class).value(); String statement = db.getDaoStatementProvider().getStatement(sql, db.getMode()); return executeStatement(method, args, statement); } else { throw new IciqlException("Can not invoke non-dao method {0}.{1}", method.getDeclaringClass().getSimpleName(), method.getName()); } } catch (InvocationTargetException te) { throw te.getCause(); } } /** * Execute a query. * * @param method * @param methodArgs * @param sql * @return the result */ private Object executeQuery(Method method, Object[] methodArgs, String sql) { /* * Determine and validate the return type */ Class returnType = method.getReturnType(); if (void.class == returnType) { throw new IciqlException("You must specify a return type for @{0} {1}.{2}!", SqlQuery.class.getSimpleName(), method.getDeclaringClass().getSimpleName(), method.getName()); } if (Collection.class.isAssignableFrom(returnType)) { throw new IciqlException("You may not return a collection for an @{0} method, please change the return type of {1}.{2} to YourClass[]!", SqlQuery.class.getSimpleName(), method.getDeclaringClass().getSimpleName(), method.getName()); } boolean isArray = false; if (returnType.isArray()) { isArray = true; returnType = returnType.getComponentType(); } boolean isJavaType = returnType.isEnum() || returnType.isPrimitive() || java.lang.Boolean.class.isAssignableFrom(returnType) || java.lang.Number.class.isAssignableFrom(returnType) || java.lang.String.class.isAssignableFrom(returnType) || java.util.Date.class.isAssignableFrom(returnType) || byte[].class.isAssignableFrom(returnType); Class> adapter = Utils.getDataTypeAdapter(method.getAnnotations()); if (adapter == null) { adapter = Utils.getDataTypeAdapter(returnType.getAnnotations()); } /* * Prepare & execute sql */ PreparedSql preparedSql = prepareSql(method, methodArgs, sql); List objects; if (!isJavaType && adapter == null) { // query of an Iciql model objects = db.executeQuery(returnType, preparedSql.sql, preparedSql.parameters); } else { // query of (array of) standard Java type or a DataTypeAdapter type objects = Utils.newArrayList(); ResultSet rs = db.executeQuery(preparedSql.sql, preparedSql.parameters); try { while (rs.next()) { Object value = db.getDialect().deserialize(rs, 1, returnType, adapter); objects.add(value); if (!isArray) { // we are not returning an array so we break // the loop and return the first result break; } } } catch (SQLException e) { throw new IciqlException(e); } finally { JdbcUtils.closeSilently(rs); } } /* * Return the results */ if (objects == null || objects.isEmpty()) { // no results if (isArray) { // return an empty array return Array.newInstance(returnType, 0); } // nothing to return! return null; } else if (isArray) { // return an array of object results Object array = Array.newInstance(returnType, objects.size()); for (int i = 0; i < objects.size(); i++) { Array.set(array, i, objects.get(i)); } return array; } // return first element return objects.get(0); } /** * Execute a statement. * * @param method * @param methodArgs * @param sql * @return the result */ private Object executeStatement(Method method, Object[] methodArgs, String sql) { /* * Determine and validate the return type */ Class returnType = method.getReturnType(); if (void.class != returnType && boolean.class != returnType && int.class != returnType) { throw new IciqlException("Invalid return type '{0}' for @{1} {2}.{3}!", returnType.getSimpleName(), SqlQuery.class.getSimpleName(), method.getDeclaringClass().getSimpleName(), method.getName()); } /* * Prepare & execute sql */ PreparedSql preparedSql = prepareSql(method, methodArgs, sql); int rows = db.executeUpdate(preparedSql.sql, preparedSql.parameters); /* * Return the results */ if (void.class == returnType) { // return nothing return null; } else if (boolean.class == returnType) { // return true if any rows were affected return rows > 0; } else { // return number of rows return rows; } } /** * Prepares an sql statement and execution parameters based on the supplied * method and it's arguments. * * @param method * @param methodArgs * @param sql * @return a prepared sql statement and arguments */ private PreparedSql prepareSql(Method method, Object[] methodArgs, String sql) { if (methodArgs == null || methodArgs.length == 0) { // no method arguments return new PreparedSql(sql, null); } IndexedSql indexedSql = indexedSqlCache.get(method); if (indexedSql == null) { // index the sql and method args indexedSql = indexSql(method, sql); // cache the indexed sql for re-use indexedSqlCache.put(method, indexedSql); } final PreparedSql preparedSql = indexedSql.prepareSql(db, methodArgs); return preparedSql; } /** * Indexes an sql statement and method args based on the supplied * method and it's arguments. * * @param method * @param sql * @return an indexed sql statement and arguments */ private IndexedSql indexSql(Method method, String sql) { Map parameterIndex = buildParameterIndex(method); // build a regex to extract parameter names from the sql statement StringBuilder sb = new StringBuilder(); sb.append(bindingDelimiter); sb.append("{1}(\\?"); for (String name : parameterIndex.keySet()) { sb.append("|"); // strip binding delimeter from name sb.append(name); } sb.append(')'); // identify parameters, replace with the '?' PreparedStatement // delimiter and build the PreparedStatement parameters array final String regex = sb.toString(); final Pattern p = Pattern.compile(regex); final Matcher m = p.matcher(sql); final StringBuffer buffer = new StringBuffer(); List indexedArgs = Utils.newArrayList(); int count = 0; while (m.find()) { String binding = m.group(1); m.appendReplacement(buffer, "?"); IndexedArgument indexedArg; if ("?".equals(binding)) { // standard ? JDBC placeholder indexedArg = parameterIndex.get("arg" + count); } else { // named placeholder indexedArg = parameterIndex.get(binding); } if (indexedArg == null) { throw new IciqlException("Unbound SQL parameter '{0}' in {1}.{2}", binding, method.getDeclaringClass().getSimpleName(), method.getName()); } indexedArgs.add(indexedArg); count++; } m.appendTail(buffer); final String statement = buffer.toString(); // create an IndexedSql container for the statement and indexes return new IndexedSql(statement, Collections.unmodifiableList(indexedArgs)); } /** * Builds an index of parameter name->(position,typeAdapter) from the method arguments * array. This index is calculated once per method. * * @param method * @return a bindings map of ("name", IndexedArgument) pairs */ private Map buildParameterIndex(Method method) { Map index = new TreeMap(); Annotation [][] annotationsMatrix = method.getParameterAnnotations(); for (int i = 0; i < annotationsMatrix.length; i++) { Annotation [] annotations = annotationsMatrix[i]; /* * Conditionally map the bean properties of the method argument * class to Method and Field instances. */ BindBean bean = getAnnotation(BindBean.class, annotations); if (bean != null) { final String prefix = bean.value(); final Class argumentClass = method.getParameterTypes()[i]; Map beanIndex = buildBeanIndex(i, prefix, argumentClass); index.putAll(beanIndex); } Class> typeAdapter = Utils.getDataTypeAdapter(annotations); final IndexedArgument indexedArgument = new IndexedArgument(i, typeAdapter); // :N - 1-indexed, like JDBC ResultSet index.put("" + (i + 1), indexedArgument); // argN - 0-indexed, like Reflection index.put("arg" + i, indexedArgument); // Bound name Bind binding = getAnnotation(Bind.class, annotations); if (binding!= null && !binding.value().isEmpty()) { index.put(binding.value(), indexedArgument); } // try mapping Java 8 argument names, may overwrite argN try { Class nullArgs = null; Method getParameters = method.getClass().getMethod("getParameters", nullArgs); if (getParameters != null) { Object [] parameters = (Object []) getParameters.invoke(method, nullArgs); if (parameters != null) { Object o = parameters[i]; Method getName = o.getClass().getMethod("getName", nullArgs); String j8name = getName.invoke(o, nullArgs).toString(); if (!j8name.isEmpty()) { index.put(j8name, indexedArgument); } } } } catch (Throwable t) { } } return index; } /** * Builds an index of parameter name->(position,method) from the method arguments * array. This index is calculated once per method. * * @param argumentIndex * @param prefix * @param beanClass * @return a bindings map of ("prefix.property", IndexedArgument) pairs */ private Map buildBeanIndex(int argumentIndex, String prefix, Class beanClass) { final String beanPrefix = StringUtils.isNullOrEmpty(prefix) ? "" : (prefix + "."); final Map index = new TreeMap(); // map JavaBean property getters for (Method method : beanClass.getMethods()) { if (Modifier.isStatic(method.getModifiers()) || method.getReturnType() == void.class || method.getParameterTypes().length > 0 || method.getDeclaringClass() == Object.class) { // not a JavaBean property continue; } final String propertyName; final String name = method.getName(); if (name.startsWith("get")) { propertyName = method.getName().substring(3); } else if (name.startsWith("is")) { propertyName = method.getName().substring(2); } else { propertyName = null; } if (propertyName == null) { // not a conventional JavaBean property continue; } final String binding = beanPrefix + preparePropertyName(propertyName); final IndexedArgument indexedArg = new IndexedArgument(argumentIndex, method); index.put(binding, indexedArg); } // map public instance fields for (Field field : beanClass.getFields()) { if (Modifier.isStatic(field.getModifiers())) { // not a JavaBean property continue; } final String binding = beanPrefix + preparePropertyName(field.getName()); final IndexedArgument indexedArg = new IndexedArgument(argumentIndex, field); index.put(binding, indexedArg); } return index; } @SuppressWarnings("unchecked") private T getAnnotation(Class annotationClass, Annotation [] annotations) { if (annotations != null) { for (Annotation annotation : annotations) { if (annotation.annotationType() == annotationClass) { return (T) annotation; } } } return null; } private String preparePropertyName(String value) { return Character.toLowerCase(value.charAt(0)) + value.substring(1); } /* * * Standard Dao method implementations delegate to the underlying Db * */ @Override public final Db db() { return db; } @Override public final boolean insert(T t) { return db.insert(t); } @Override public final void insertAll(List t) { db.insertAll(t); } @Override public final long insertAndGetKey(T t) { return db.insertAndGetKey(t); } @Override public final List insertAllAndGetKeys(List t) { return db.insertAllAndGetKeys(t); } @Override public final boolean update(T t) { return db.update(t); } @Override public final void updateAll(List t) { db.updateAll(t); } @Override public final void merge(T t) { db.merge(t); } @Override public final boolean delete(T t) { return db.delete(t); } @Override public final void deleteAll(List t) { db.deleteAll(t); } @Override public final void close() { db.close(); } /** * Container class to hold the prepared JDBC SQL statement and execution * parameters. */ private class PreparedSql { final String sql; final Object [] parameters; PreparedSql(String sql, Object [] parameters) { this.sql = sql; this.parameters = parameters; } @Override public String toString() { return sql; } } /** * Container class to hold a parsed JDBC SQL statement and * IndexedParameters. *

* Instances of this class are cached because they are functional processing * containers as they contain Method and Field references for binding beans * and matching to method arguments. *

*/ private class IndexedSql { final String sql; final List indexedArgs; IndexedSql(String sql, List indexedArgs) { this.sql = sql; this.indexedArgs = indexedArgs; } /** * Prepares the method arguments for statement execution. * * @param db * @param methodArgs * @return the prepared sql statement and parameters */ PreparedSql prepareSql(Db db, Object [] methodArgs) { Object [] parameters = new Object[indexedArgs.size()]; for (int i = 0; i < indexedArgs.size(); i++) { IndexedArgument indexedArg = indexedArgs.get(i); Object methodArg = methodArgs[indexedArg.index]; Object value = methodArg; Class> typeAdapter = indexedArg.typeAdapter; if (indexedArg.method != null) { // execute the bean method try { value = indexedArg.method.invoke(methodArg); typeAdapter = Utils.getDataTypeAdapter(indexedArg.method.getAnnotations()); } catch (Exception e) { throw new IciqlException(e); } } else if (indexedArg.field != null) { // extract the field value try { value = indexedArg.field.get(methodArg); typeAdapter = Utils.getDataTypeAdapter(indexedArg.field.getAnnotations()); } catch (Exception e) { throw new IciqlException(e); } } else if (typeAdapter == null) { // identify the type adapter for the argument class typeAdapter = Utils.getDataTypeAdapter(methodArg.getClass().getAnnotations()); } // prepare the parameter parameters[i] = db.getDialect().serialize(value, typeAdapter); } return new PreparedSql(sql, parameters); } @Override public String toString() { return sql; } } /** * IndexedArgument holds cached information about how to process an method * argument by it's index in the method arguments array. *

* An argument may be passed-through, might be bound to a bean property, * might be transformed with a type adapter, or a combination of these. *

*/ private class IndexedArgument { final int index; final Class> typeAdapter; final Method method; final Field field; IndexedArgument(int index, Class> typeAdapter) { this.index = index; this.typeAdapter = typeAdapter; this.method = null; this.field = null; } IndexedArgument(int methodArgIndex, Method method) { this.index = methodArgIndex; this.typeAdapter = null; this.method = method; this.field = null; } IndexedArgument(int methodArgIndex, Field field) { this.index = methodArgIndex; this.typeAdapter = null; this.method = null; this.field = field; } @Override public String toString() { String accessor; if (method != null) { accessor = "M:" + method.getDeclaringClass().getSimpleName() + "." + method.getName(); } else if (field != null) { accessor = "F:" + field.getDeclaringClass().getSimpleName() + "." + field.getName(); } else { accessor = "A:arg"; } return index + ":" + accessor + (typeAdapter == null ? "" : (":" + typeAdapter.getSimpleName())); } } }