org.forgerock.openidm.repo.jdbc.impl.query.TableQueries.java Source code

Java tutorial

Introduction

Here is the source code for org.forgerock.openidm.repo.jdbc.impl.query.TableQueries.java

Source

/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 *
 * Copyright (c) 2011-2015 ForgeRock AS. All Rights Reserved
 *
 * The contents of this file are subject to the terms
 * of the Common Development and Distribution License
 * (the License). You may not use this file except in
 * compliance with the License.
 *
 * You can obtain a copy of the License at
 * http://forgerock.org/license/CDDLv1.0.html
 * See the License for the specific language governing
 * permission and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL
 * Header Notice in each file and include the License file
 * at http://forgerock.org/license/CDDLv1.0.html
 * If applicable, add the following below the CDDL Header,
 * with the fields enclosed by brackets [] replaced by
 * your own identifying information:
 * "Portions Copyrighted [year] [name of copyright owner]"
 */

package org.forgerock.openidm.repo.jdbc.impl.query;

import static org.forgerock.json.JsonValue.json;
import static org.forgerock.json.JsonValue.object;
import static org.forgerock.openidm.repo.QueryConstants.PAGED_RESULTS_OFFSET;
import static org.forgerock.openidm.repo.QueryConstants.PAGE_SIZE;
import static org.forgerock.openidm.repo.QueryConstants.QUERY_EXPRESSION;
import static org.forgerock.openidm.repo.QueryConstants.QUERY_FILTER;
import static org.forgerock.openidm.repo.QueryConstants.QUERY_ID;

import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.apache.commons.lang3.StringUtils;
import org.forgerock.json.JsonPointer;
import org.forgerock.json.JsonValue;
import org.forgerock.json.resource.BadRequestException;
import org.forgerock.json.resource.InternalServerErrorException;
import org.forgerock.json.resource.ResourceException;
import org.forgerock.openidm.core.ServerConstants;
import org.forgerock.openidm.repo.jdbc.TableHandler;
import org.forgerock.openidm.repo.jdbc.impl.CleanupHelper;
import org.forgerock.openidm.repo.jdbc.impl.GenericTableHandler.QueryDefinition;
import org.forgerock.openidm.repo.util.TokenHandler;
import org.forgerock.openidm.smartevent.EventEntry;
import org.forgerock.openidm.smartevent.Name;
import org.forgerock.openidm.smartevent.Publisher;
import org.forgerock.util.query.QueryFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Configured and add-hoc query support on tables in generic (non-object
 * specific) layout
 *
 * Queries can contain tokens of the format ${token-name}
 *
 */
public class TableQueries {

    final static Logger logger = LoggerFactory.getLogger(TableQueries.class);

    public static final String PREFIX_INT = "int";

    public static final String PREFIX_LIST = "list";

    // Monitoring event name prefix
    static final String EVENT_RAW_QUERY_PREFIX = "openidm/internal/repo/jdbc/raw/query/";

    /**
     * Helper class to wrap configured queries/commands.
     */
    class ConfiguredQueries {
        private Map<String, QueryInfo> configured = new HashMap<String, QueryInfo>();

        void setConfiguredQueries(Map<String, String> replacements, JsonValue queriesConfig) {
            configured.clear();
            for (String queryName : queriesConfig.keys()) {
                String rawQuery = queriesConfig.get(queryName).required().asString();

                TokenHandler tokenHandler = new TokenHandler();
                // Replace the table name tokens.
                String tempQueryString = tokenHandler.replaceSomeTokens(rawQuery, replacements);

                // Convert to ? for prepared statement, populate token replacement info
                List<String> tokenNames = tokenHandler.extractTokens(tempQueryString);
                String queryString = tokenHandler.replaceTokens(tempQueryString, "?", PREFIX_LIST);

                QueryInfo queryInfo = new QueryInfo(queryString, tokenNames);
                configured.put(queryName, queryInfo);
                logger.debug("Configured query converted to JDBC query {} and tokens {}", queryString, tokenNames);
            }
        }

        /**
         * Returns the QueryInfo for a queryId.
         *
         * @param queryId the unique identifier of the parameterized, pre-defined query
         * @return the QueryInfo
         */
        QueryInfo getQueryInfo(String queryId) {
            return configured.get(queryId);
        }

        /**
         * Gets and resolves a query by id, using token substitution
         *
         * @param con The db connection
         * @param queryId the unique identifier of the paramteerized, pre-defined query
         * @param type the resource component name targeted by the URI
         * @param params the parameters passed into the query call
         * @return The statement
         * @throws SQLException if resolving the statement failed
         * @throws BadRequestException if no query is defined for the given identifier
         */
        PreparedStatement getQuery(Connection con, String queryId, String type, Map<String, Object> params)
                throws SQLException, ResourceException {

            QueryInfo foundInfo = getQueryInfo(queryId);
            if (foundInfo == null) {
                throw new BadRequestException("No query defined/configured for requested queryId " + queryId);
            }
            return resolveQuery(foundInfo, con, params);
        }

        /**
         * Check if a {@code queryId} is present in the set of configured configured.
         *
         * @param queryId Id of the query to check for
         *
         * @return true if the queryId is present in the set of configured configured.
         */
        public boolean queryIdExists(final String queryId) {
            return configured.containsKey(queryId);
        }
    }

    /** Configured queries */
    final ConfiguredQueries queries = new ConfiguredQueries();

    /** Configured commands */
    final ConfiguredQueries commands = new ConfiguredQueries();

    final String mainTableName;
    final String propTableName;
    final String dbSchemaName;

    /** Max length of a property. Used for trimming incoming query values */
    final int maxPropLen;

    final QueryResultMapper resultMapper;

    private TableHandler tableHandler;

    /**
     * Constructor.
     *
     * @param tableHandler
     * @param mainTableName
     * @param propTableName
     * @param dbSchemaName
     * @param maxPropLen Max length of propvalues. Used for trimming values if > 0.
     * @param resultMapper
     */
    public TableQueries(TableHandler tableHandler, String mainTableName, String propTableName, String dbSchemaName,
            int maxPropLen, QueryResultMapper resultMapper) {
        this.tableHandler = tableHandler;
        this.mainTableName = mainTableName;
        this.propTableName = propTableName;
        this.dbSchemaName = dbSchemaName;
        this.maxPropLen = maxPropLen;
        this.resultMapper = resultMapper;
    }

    /**
     * Get a prepared statement for the given connection and SQL. May come from
     * a cache (either local or the host container)
     *
     * @param connection
     *            db connection to get a prepared statement for
     * @param sql
     *            the prepared statement SQL
     * @return the prepared statement
     * @throws SQLException
     *             if parsing or retrieving the prepared statement failed
     */
    public PreparedStatement getPreparedStatement(Connection connection, String sql) throws SQLException {
        return getPreparedStatement(connection, sql, false);
    }

    /**
     * Get a prepared statement for the given connection and SQL. May come from
     * a cache (either local or the host container)
     *
     * @param connection
     *            db connection to get a prepared statement for
     * @param sql
     *            the prepared statement SQL
     * @param autoGeneratedKeys
     *            whether to return auto-generated keys by the DB
     * @return the prepared statement
     * @throws SQLException
     *             if parsing or retrieving the prepared statement failed
     */
    public PreparedStatement getPreparedStatement(Connection connection, String sql, boolean autoGeneratedKeys)
            throws SQLException {
        // This is where local prepared statement caching could be added for
        // stand-alone operation.

        // In the context of a (JavaEE) container rely on its built-in prepared
        // statement caching
        // rather than doing it explicitly here.
        if (autoGeneratedKeys) {
            return connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
        } else {
            return connection.prepareStatement(sql);
        }
    }

    /**
     * Get a prepared statement for the given connection and SQL. Returns the
     * generated Key This is a function used by OracleTableHandler. Since ORACLE
     * does not return the auto incremented key but the ROWID on using
     * getGeneratedKeys(), we have to pass a string array containing the column
     * that has been auto incremented. I.E. passing 'id' as the only entry of
     * this array to this method will return the value of the id-column instead
     * of the ROWID
     *
     * @param connection
     *            db connection to get a prepared statement for
     * @param sql
     *            the prepared statement SQL
     * @param columns
     *            which column shall be returned as the value of
     *            PreparedStatement.getGeneratedKeys()
     * @return the prepared statement
     * @throws SQLException
     *             if parsing or retrieving the prepared statement failed
     */
    public PreparedStatement getPreparedStatement(Connection connection, String sql, String[] columns)
            throws SQLException {
        return connection.prepareStatement(sql, columns);
    }

    /**
     * Execute a query, either a pre-configured query by using the query ID, or
     * a query expression passed as part of the params.
     *
     * The keys for the input parameters as well as the return map entries are
     * in QueryConstants.
     *
     * @param type
     *            the resource component name targeted by the URI
     * @param params
     *            the parameters which include the query id, or the query
     *            expression, as well as the token key/value pairs to replace in
     *            the query
     * @param con
     *            a handle to a database connection newBuilder for exclusive use
     *            by the query method whilst it is executing.
     * @return The query result, which includes meta-data about the query, and
     *         the result set itself.
     * @throws BadRequestException
     *             if the passed request parameters are invalid, e.g. missing
     *             query id or query expression or tokens.
     * @throws InternalServerErrorException
     *             if the preparing or executing the query fails because of
     *             configuration or DB issues
     */
    public List<Map<String, Object>> query(final String type, Map<String, Object> params, Connection con)
            throws ResourceException {

        List<Map<String, Object>> result = null;
        params.put(ServerConstants.RESOURCE_NAME, type);

        // If paged results are requested then decode the cookie in order to determine
        // the index of the first result to be returned.
        final int requestPageSize = (Integer) params.get(PAGE_SIZE);

        final String offsetParam;
        final String pageSizeParam;

        if (requestPageSize > 0) {
            offsetParam = String.valueOf((Integer) params.get(PAGED_RESULTS_OFFSET));
            pageSizeParam = String.valueOf(requestPageSize);
        } else {
            offsetParam = "0";
            pageSizeParam = String.valueOf(Integer.MAX_VALUE);
        }

        params.put(PAGED_RESULTS_OFFSET, offsetParam);
        params.put(PAGE_SIZE, pageSizeParam);
        QueryFilter<JsonPointer> queryFilter = (QueryFilter) params.get(QUERY_FILTER);
        String queryExpression = (String) params.get(QUERY_EXPRESSION);
        String queryId = (String) params.get(QUERY_ID);
        if (queryId == null && queryExpression == null && queryFilter == null) {
            throw new BadRequestException("Either " + QUERY_ID + ", " + QUERY_EXPRESSION + ", or " + QUERY_FILTER
                    + " to identify/define a query must be passed in the parameters. " + params);
        }
        logger.debug("Querying " + params);
        final PreparedStatement foundQuery;
        try {
            if (queryFilter != null) {
                foundQuery = parseQueryFilter(con, queryFilter, params);
            } else if (queryExpression != null) {
                foundQuery = resolveInlineQuery(con, queryExpression, params);
            } else if (queries.queryIdExists(queryId)) {
                foundQuery = queries.getQuery(con, queryId, type, params);
            } else {
                throw new BadRequestException("The passed query identifier " + queryId
                        + " does not match any configured queries on the JDBC repository service.");
            }
        } catch (SQLException ex) {
            final String queryDescription;
            if (queryFilter != null) {
                queryDescription = queryFilter.toString();
            } else if (queryExpression != null) {
                queryDescription = queryExpression;
            } else {
                queryDescription = queries.getQueryInfo(queryId).getQueryString();
            }
            throw new InternalServerErrorException("DB reported failure preparing query: " + queryDescription
                    + " with params: " + params + " error code: " + ex.getErrorCode() + " sqlstate: "
                    + ex.getSQLState() + " message: " + ex.getMessage(), ex);
        }

        Name eventName = getEventName(queryId);
        EventEntry measure = Publisher.start(eventName, foundQuery, null);
        ResultSet rs = null;
        try {
            rs = foundQuery.executeQuery();
            result = resultMapper.mapQueryToObject(rs, queryId, type, params, this);
            measure.setResult(result);
        } catch (SQLException ex) {
            throw new InternalServerErrorException("DB reported failure executing query " + foundQuery.toString()
                    + " with params: " + params + " error code: " + ex.getErrorCode() + " sqlstate: "
                    + ex.getSQLState() + " message: " + ex.getMessage(), ex);
        } catch (IOException ex) {
            throw new InternalServerErrorException("Failed to convert result objects for query "
                    + foundQuery.toString() + " with params: " + params + " message: " + ex.getMessage(), ex);
        } finally {
            CleanupHelper.loggedClose(rs);
            CleanupHelper.loggedClose(foundQuery);
            measure.end();
        }
        return result;
    }

    public Integer command(final String type, Map<String, Object> params, Connection con) throws ResourceException {

        Integer result = null;
        params.put(ServerConstants.RESOURCE_NAME, type);

        String queryExpression = (String) params.get("commandExpression");
        String queryId = (String) params.get("commandId");
        if (queryId == null && queryExpression == null) {
            throw new BadRequestException("Either " + "commandId" + " or " + "commandExpression"
                    + " to identify/define a query must be passed in the parameters. " + params);
        }
        final PreparedStatement foundQuery;
        try {
            if (queryExpression != null) {
                foundQuery = resolveInlineQuery(con, queryExpression, params);
            } else if (commands.queryIdExists(queryId)) {
                foundQuery = commands.getQuery(con, queryId, type, params);
            } else {
                throw new BadRequestException("The passed command identifier " + queryId
                        + " does not match any configured commands on the JDBC repository service.");
            }
        } catch (SQLException ex) {
            throw new InternalServerErrorException("DB reported failure preparing command: "
                    + (queryExpression != null ? queryExpression : commands.getQueryInfo(queryId).getQueryString())
                    + " with params: " + params + " error code: " + ex.getErrorCode() + " sqlstate: "
                    + ex.getSQLState() + " message: " + ex.getMessage(), ex);
        }

        Name eventName = getEventName(queryId);
        EventEntry measure = Publisher.start(eventName, foundQuery, null);
        ResultSet rs = null;
        try {
            result = foundQuery.executeUpdate();
            measure.setResult(result);
        } catch (SQLException ex) {
            throw new InternalServerErrorException("DB reported failure executing query " + foundQuery.toString()
                    + " with params: " + params + " error code: " + ex.getErrorCode() + " sqlstate: "
                    + ex.getSQLState() + " message: " + ex.getMessage(), ex);
        } finally {
            CleanupHelper.loggedClose(rs);
            CleanupHelper.loggedClose(foundQuery);
            measure.end();
        }
        return result;
    }

    /**
     * Whether a result set contains a given column
     *
     * @param rsMetaData
     *            result set meta data
     * @param columnName
     *            name of the column to look for
     * @return true if it is present
     * @throws SQLException
     *             if meta data inspection failed
     */
    public boolean hasColumn(ResultSetMetaData rsMetaData, String columnName) throws SQLException {
        for (int colPos = 1; colPos <= rsMetaData.getColumnCount(); colPos++) {
            if (columnName.equalsIgnoreCase(rsMetaData.getColumnName(colPos))) {
                return true;
            }
        }
        return false;
    }

    /**
     * Resolves a query filter.
     *
     * @param con
     *            The db connection
     * @param filter
     *            the query filter to parse
     * @return A resolved statement
     */
    PreparedStatement parseQueryFilter(Connection con, QueryFilter<JsonPointer> filter, Map<String, Object> params)
            throws SQLException, ResourceException {
        Map<String, Object> replacementTokens = new LinkedHashMap<String, Object>();

        String rawQuery = tableHandler.renderQueryFilter(filter, replacementTokens, params);

        Map<String, String> replacements = new LinkedHashMap<String, String>();
        replacements.put("_mainTable", mainTableName);
        replacements.put("_propTable", propTableName);
        replacements.put("_dbSchema", dbSchemaName);

        TokenHandler tokenHandler = new TokenHandler();
        // Replace the table name tokens.
        String tempQueryString = tokenHandler.replaceSomeTokens(rawQuery, replacements);

        logger.debug("Tokenized statement: {} with replacementTokens: {}", rawQuery, replacementTokens);

        // Convert to ? for prepared statement, populate token replacement info
        List<String> tokenNames = tokenHandler.extractTokens(tempQueryString);
        String queryString = tokenHandler.replaceTokens(tempQueryString, "?", PREFIX_LIST);

        QueryInfo queryInfo = new QueryInfo(queryString, tokenNames);
        return resolveQuery(queryInfo, con, replacementTokens);
    }

    /**
     * Resolves a full query expression Currently does not support token
     * replacement
     *
     * @param con
     *            The db connection
     * @param queryExpression
     *            the native query string
     * @param params
     *            parameters passed to the resource query
     * @return A resolved statement
     */
    PreparedStatement resolveInlineQuery(Connection con, String queryExpression, Map<String, Object> params)
            throws SQLException, ResourceException {
        // No token replacement on expressions for now
        List<String> tokenNames = new ArrayList<String>();
        QueryInfo info = new QueryInfo(queryExpression, tokenNames);
        return resolveQuery(info, con, params);
    }

    /**
     * Check if a {@code queryId} is present in the set of configured queries.
     *
     * @param queryId Id of the query to check for
     *
     * @return true if the queryId is present in the set of configured queries.
     */
    public boolean queryIdExists(final String queryId) {
        return queries.queryIdExists(queryId);
    }

    /**
     * Resolves a query, given a QueryInfo
     *
     * @param info
     *            The info encapsulating the query information
     * @param con
     *            the db connection
     * @param params
     *            the parameters passed to query
     * @return the resolved query
     * @throws SQLException
     *             if resolving the query failed
     */
    PreparedStatement resolveQuery(QueryInfo info, Connection con, Map<String, Object> params)
            throws SQLException, ResourceException {
        String queryStr = info.getQueryString();
        List<String> tokenNames = info.getTokenNames();

        // replace ${list:variable} tokens with the correct number of bind variables
        Map<String, Integer> listReplacements = new HashMap<String, Integer>();
        for (String tokenName : tokenNames) {
            String[] tokenParts = tokenName.split(":", 2);
            if (PREFIX_LIST.equals(tokenParts[0]) && params.containsKey(tokenParts[1])) {
                listReplacements.put(tokenName, ((String) params.get(tokenParts[1])).split(",").length);
            }
        }
        if (listReplacements.size() > 0) {
            TokenHandler tokenHandler = new TokenHandler();
            queryStr = tokenHandler.replaceListTokens(queryStr, listReplacements, "?");
        }

        // now prepare the statement using the correct number of bind variables
        PreparedStatement statement = getPreparedStatement(con, queryStr);
        int count = 1; // DB column count starts at 1
        for (String tokenName : tokenNames) {
            String[] tokenParts = tokenName.split(":", 2);
            if (tokenParts.length == 1) {
                // handle single value - assume String
                Object objValue = params.get(tokenName);
                String value = null;
                if (objValue != null) {
                    value = trimValue(objValue);
                } else {
                    // fail with an exception if token not found
                    throw new BadRequestException("Missing entry in params passed to query for token " + tokenName);
                }
                statement.setString(count, value);
                count++;
            } else {
                Object objValue = params.get(tokenParts[1]);
                if (objValue == null) {
                    // fail with an exception if token not found
                    throw new BadRequestException("Missing entry in params passed to query for token " + tokenName);
                }
                if (PREFIX_INT.equals(tokenParts[0])) {
                    // handle single integer value
                    Integer int_value = null;
                    if (objValue != null) {
                        int_value = Integer.parseInt(objValue.toString());
                    }
                    statement.setInt(count, int_value);
                    count++;
                } else if (PREFIX_LIST.equals(tokenParts[0])) {
                    // handle list of values - presently assumes Strings, TODO support integer lists
                    if (objValue != null) {
                        for (String list_value : objValue.toString().split(",")) {
                            // if list value is surrounded by single quotes remove them
                            if (list_value != null && list_value.startsWith("'") && list_value.endsWith("'")) {
                                list_value = list_value.substring(1, list_value.length() - 1);
                            }
                            statement.setString(count, trimValue(list_value));
                            count++;
                        }
                    } else {
                        statement.setString(count, null);
                        count++;
                    }
                }
            }
        }
        logger.debug("Prepared statement: {}", statement);

        return statement;
    }

    /**
     * Set the pre-configured queries/commands for generic tables, which are identified
     * by a query identifier and can be invoked using this identifier
     *
     * Success to set the queries does not mean they are valid as some can only
     * be validated at query execution time.
     *
     * @param queriesConfig
     *            queries configured in configuration (files)
     * @param defaultQueryMap
     *            static default queries already defined for handling this table
     *            type
     *
     *            query details
     */
    public void setConfiguredQueries(JsonValue queriesConfig, JsonValue commandsConfig,
            Map<QueryDefinition, String> defaultQueryMap) {
        Map<String, String> replacements = new HashMap<String, String>();
        replacements.put("_mainTable", mainTableName);
        replacements.put("_propTable", propTableName);
        replacements.put("_dbSchema", dbSchemaName);

        setConfiguredQueries(replacements, queriesConfig, commandsConfig, defaultQueryMap);
    }

    /**
     * Set the pre-configured queries/commands for explicitly mapped tables, which are
     * identified by a query identifier and can be invoked using this identifier
     *
     * Success to set the queries does not mean they are valid as some can only
     * be validated at query execution time.
     *
     * @param tableName
     *            name of the explicitly mapped table
     * @param dbSchemaName
     *            the database scheme the table is in
     * @param queriesConfig
     *            queries configured in configuration (files)
     * @param defaultQueryMap
     *            static default queries already defined for handling this table
     *            type
     *
     *            query details
     */
    public void setConfiguredQueries(String tableName, String dbSchemaName, JsonValue queriesConfig,
            JsonValue commandsConfig, Map<QueryDefinition, String> defaultQueryMap) {
        Map<String, String> replacements = new HashMap<String, String>();
        replacements.put("_table", tableName);
        replacements.put("_dbSchema", dbSchemaName);

        setConfiguredQueries(replacements, queriesConfig, commandsConfig, defaultQueryMap);
    }

    private void setConfiguredQueries(Map<String, String> replacements, JsonValue queriesConfig,
            JsonValue commandsConfig, Map<QueryDefinition, String> defaultQueryMap) {

        if (queriesConfig == null || queriesConfig.isNull()) {
            queriesConfig = json(object());
        }
        if (commandsConfig == null || commandsConfig.isNull()) {
            commandsConfig = json(object());
        }

        // Default query-all-ids to allow bootstrapping of configuration
        if (!queriesConfig.isDefined(ServerConstants.QUERY_ALL_IDS) && defaultQueryMap != null) {
            queriesConfig.put(ServerConstants.QUERY_ALL_IDS, defaultQueryMap.get(QueryDefinition.QUERYALLIDS));
        }

        queries.setConfiguredQueries(replacements, queriesConfig);
        commands.setConfiguredQueries(replacements, commandsConfig);
    }

    /**
     * @return the smartevent Name for a given query
     */
    Name getEventName(String queryId) {
        if (queryId == null) {
            return Name.get(EVENT_RAW_QUERY_PREFIX + "_query_expression");
        } else {
            return Name.get(EVENT_RAW_QUERY_PREFIX + queryId);
        }
    }

    private String trimValue(Object param) {
        return maxPropLen <= 0 ? param.toString() : StringUtils.left(param.toString(), maxPropLen);
    }
}