001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software GmbH & Co. KG, please see the
018 * company website: https://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: https://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.db.generic;
029
030import org.opencms.db.CmsDbContext;
031import org.opencms.file.CmsProject;
032import org.opencms.main.CmsLog;
033import org.opencms.main.CmsRuntimeException;
034import org.opencms.util.CmsCollectionsGenericWrapper;
035import org.opencms.util.CmsStringUtil;
036import org.opencms.util.CmsUUID;
037
038import java.io.ByteArrayInputStream;
039import java.sql.Connection;
040import java.sql.PreparedStatement;
041import java.sql.ResultSet;
042import java.sql.SQLException;
043import java.sql.Statement;
044import java.util.Collections;
045import java.util.HashMap;
046import java.util.Iterator;
047import java.util.List;
048import java.util.Map;
049import java.util.Properties;
050import java.util.concurrent.ConcurrentHashMap;
051
052import org.apache.commons.logging.Log;
053
054/**
055 * Generic (ANSI-SQL) implementation of the SQL manager.<p>
056 *
057 * @since 6.0.0
058 */
059public class CmsSqlManager extends org.opencms.db.CmsSqlManager {
060
061    /** A pattern being replaced in SQL queries to generate SQL queries to access online/offline tables. */
062    protected static final String QUERY_PROJECT_SEARCH_PATTERN = "_${PROJECT}_";
063
064    /** The log object for this class. */
065    private static final Log LOG = CmsLog.getLog(CmsSqlManager.class);
066
067    /** The filename/path of the SQL query properties. */
068    private static final String QUERY_PROPERTIES = "org/opencms/db/generic/query.properties";
069
070    /** A map to cache queries with replaced search patterns. */
071    protected ConcurrentHashMap<String, String> m_cachedQueries;
072
073    /** The type ID of the driver (vfs, user, project or history) from where this SQL manager is referenced. */
074    protected int m_driverType;
075
076    /** The pool URL to get connections from the JDBC driver manager, including DBCP's pool URL prefix. */
077    protected String m_poolUrl;
078
079    /** A map holding all SQL queries. */
080    protected Map<String, String> m_queries;
081
082    /**
083     * Creates a new, empty SQL manager.<p>
084     */
085    public CmsSqlManager() {
086
087        this(Collections.<String> emptyList());
088    }
089
090    /**
091     * Creates a new SQL manager and loads additional SQL query property files after the generic queries.<p>
092     *
093     * @param additionalQueryProperties the additional SQL query property files to load
094     */
095    public CmsSqlManager(List<String> additionalQueryProperties) {
096
097        m_cachedQueries = new ConcurrentHashMap<String, String>();
098        m_queries = new HashMap<String, String>();
099        loadQueryProperties(QUERY_PROPERTIES);
100        loadAdditionalQueryProperties(additionalQueryProperties);
101    }
102
103    /**
104     * Creates a new instance of a SQL manager.<p>
105     *
106     * @param classname the classname of the SQL manager
107     *
108     * @return a new instance of the SQL manager
109     */
110    public static org.opencms.db.generic.CmsSqlManager getInstance(String classname) {
111
112        return getInstance(classname, Collections.<String> emptyList());
113    }
114
115    /**
116     * Creates a new instance of a SQL manager and loads additional SQL query property files.<p>
117     *
118     * @param classname the classname of the SQL manager
119     * @param additionalQueryProperties the additional SQL query property files to load
120     *
121     * @return a new instance of the SQL manager
122     */
123    public static org.opencms.db.generic.CmsSqlManager getInstance(
124        String classname,
125        List<String> additionalQueryProperties) {
126
127        org.opencms.db.generic.CmsSqlManager sqlManager;
128
129        try {
130            Class<?> sqlManagerClass = Class.forName(classname);
131            Object objectInstance;
132            try {
133                objectInstance = sqlManagerClass.getConstructor(List.class).newInstance(additionalQueryProperties);
134            } catch (NoSuchMethodException e) {
135                if ((additionalQueryProperties == null) || additionalQueryProperties.isEmpty()) {
136                    objectInstance = sqlManagerClass.newInstance();
137                } else {
138                    throw e;
139                }
140            }
141            sqlManager = (org.opencms.db.generic.CmsSqlManager)objectInstance;
142        } catch (Throwable t) {
143            LOG.error(Messages.get().getBundle().key(Messages.LOG_SQL_MANAGER_INIT_FAILED_1, classname), t);
144            sqlManager = null;
145        }
146
147        if (CmsLog.INIT.isInfoEnabled()) {
148            CmsLog.INIT.info(Messages.get().getBundle().key(Messages.INIT_DRIVER_SQL_MANAGER_1, classname));
149        }
150
151        return sqlManager;
152
153    }
154
155    /**
156     * Replaces the project search pattern in SQL queries by the pattern _ONLINE_ or _OFFLINE_ depending on the
157     * specified project ID.<p>
158     *
159     * @param projectId the ID of the current project
160     * @param query the SQL query
161     * @return String the SQL query with the table key search pattern replaced
162     */
163    protected static String replaceProjectPattern(CmsUUID projectId, String query) {
164
165        // make the statement project dependent
166        String replacePattern = ((projectId == null) || projectId.equals(CmsProject.ONLINE_PROJECT_ID))
167        ? "_ONLINE_"
168        : "_OFFLINE_";
169        return CmsStringUtil.substitute(query, QUERY_PROJECT_SEARCH_PATTERN, replacePattern);
170    }
171
172    /**
173     * Attempts to close the connection, statement and result set after a statement has been executed.<p>
174     *
175     * @param dbc the current database context
176     * @param con the JDBC connection
177     * @param stmnt the statement
178     * @param res the result set
179     */
180    public void closeAll(CmsDbContext dbc, Connection con, Statement stmnt, ResultSet res) {
181
182        // NOTE: The following note is outdated, since we are now using HikariCP as a connection pool,
183        // which doesn't have prepared statement pooling. Closing the connection in a finally block or using
184        // the try-with-resources syntax is sufficient.
185
186        // NOTE: (outdated) we have to close Connections/Statements that way, because a dbcp PoolablePreparedStatement
187        // is not a DelegatedStatement; for that reason its not removed from the trace of the connection when it is closed.
188        // So, the connection tries to close it again when the connection is closed itself;
189        // as a result there is an error that forces the connection to be destroyed and not pooled
190
191        if (dbc == null) {
192            LOG.error(Messages.get().getBundle().key(Messages.LOG_NULL_DB_CONTEXT_0));
193        }
194
195        try {
196            // first, close the result set
197            if (res != null) {
198                res.close();
199            }
200        } catch (SQLException e) {
201            LOG.debug(e.getLocalizedMessage(), e);
202        } finally {
203            res = null;
204        }
205
206        try {
207            // close the statement
208            if (stmnt != null) {
209                stmnt.close();
210            }
211        } catch (SQLException e) {
212            LOG.debug(e.getLocalizedMessage(), e);
213        } finally {
214            stmnt = null;
215        }
216
217        try {
218            // close the connection
219            if ((con != null) && !con.isClosed()) {
220                con.close();
221            }
222        } catch (SQLException e) {
223            LOG.debug(e.getLocalizedMessage(), e);
224        } finally {
225            con = null;
226        }
227
228    }
229
230    /**
231     * Retrieves the value of the designated column in the current row of this ResultSet object as
232     * a byte array in the Java programming language.<p>
233     *
234     * The bytes represent the raw values returned by the driver. Overwrite this method if another
235     * database server requires a different handling of byte attributes in tables.<p>
236     *
237     * @param res the result set
238     * @param attributeName the name of the table attribute
239     *
240     * @return byte[] the column value; if the value is SQL NULL, the value returned is null
241     *
242     * @throws SQLException if a database access error occurs
243     */
244    public byte[] getBytes(ResultSet res, String attributeName) throws SQLException {
245
246        return res.getBytes(attributeName);
247    }
248
249    /**
250     * Returns a JDBC connection from the connection pool.<p>
251     *
252     * Use this method to get a connection for reading/writing project independent data.<p>
253     *
254     * @param dbc the current database context
255     *
256     * @return a JDBC connection
257     *
258     * @throws SQLException if the project id is not supported
259     */
260    public Connection getConnection(CmsDbContext dbc) throws SQLException {
261
262        if (dbc == null) {
263            LOG.error(Messages.get().getBundle().key(Messages.LOG_NULL_DB_CONTEXT_0));
264        }
265        // match the ID to a JDBC pool URL of the OpenCms JDBC pools {online|offline|backup}
266        return getConnectionByUrl(m_poolUrl);
267    }
268
269    /**
270     * Returns a PreparedStatement for a JDBC connection specified by the key of a SQL query
271     * and the CmsProject.<p>
272     *
273     * @param con the JDBC connection
274     * @param project the specified CmsProject
275     * @param queryKey the key of the SQL query
276     *
277     * @return PreparedStatement a new PreparedStatement containing the pre-compiled SQL statement
278     *
279     * @throws SQLException if a database access error occurs
280     */
281    public PreparedStatement getPreparedStatement(Connection con, CmsProject project, String queryKey)
282    throws SQLException {
283
284        return getPreparedStatement(con, project.getUuid(), queryKey);
285    }
286
287    /**
288     * Returns a PreparedStatement for a JDBC connection specified by the key of a SQL query
289     * and the project-ID.<p>
290     *
291     * @param con the JDBC connection
292     * @param projectId the ID of the specified CmsProject
293     * @param queryKey the key of the SQL query
294     *
295     * @return PreparedStatement a new PreparedStatement containing the pre-compiled SQL statement
296     *
297     * @throws SQLException if a database access error occurs
298     */
299    public PreparedStatement getPreparedStatement(Connection con, CmsUUID projectId, String queryKey)
300    throws SQLException {
301
302        String rawSql = readQuery(projectId, queryKey);
303        return getPreparedStatementForSql(con, rawSql);
304    }
305
306    /**
307     * Returns a PreparedStatement for a JDBC connection specified by the key of a SQL query.<p>
308     *
309     * @param con the JDBC connection
310     * @param queryKey the key of the SQL query
311     * @return PreparedStatement a new PreparedStatement containing the pre-compiled SQL statement
312     * @throws SQLException if a database access error occurs
313     */
314    public PreparedStatement getPreparedStatement(Connection con, String queryKey) throws SQLException {
315
316        String rawSql = readQuery(CmsUUID.getNullUUID(), queryKey);
317        return getPreparedStatementForSql(con, rawSql);
318    }
319
320    /**
321     * Returns a PreparedStatement for a JDBC connection specified by the SQL query.<p>
322     *
323     * @param con the JDBC connection
324     * @param query the SQL query
325     * @return PreparedStatement a new PreparedStatement containing the pre-compiled SQL statement
326     * @throws SQLException if a database access error occurs
327     */
328    public PreparedStatement getPreparedStatementForSql(Connection con, String query) throws SQLException {
329
330        // unfortunately, this wrapper is essential, because some JDBC driver
331        // implementations don't accept the delegated objects of DBCP's connection pool.
332        return con.prepareStatement(query);
333    }
334
335    /**
336     * Initializes this SQL manager.<p>
337     *
338     * @param driverType the type ID of the driver (vfs,user,project or history) from where this SQL manager is referenced
339     * @param poolUrl the pool URL to get connections from the JDBC driver manager
340     */
341    public void init(int driverType, String poolUrl) {
342
343        m_driverType = driverType;
344        m_poolUrl = poolUrl;
345
346    }
347
348    /**
349     * Searches for the SQL query with the specified key and CmsProject.<p>
350     *
351     * @param project the specified CmsProject
352     * @param queryKey the key of the SQL query
353     * @return the the SQL query in this property list with the specified key
354     */
355    public String readQuery(CmsProject project, String queryKey) {
356
357        return readQuery(project.getUuid(), queryKey);
358    }
359
360    /**
361     * Searches for the SQL query with the specified key and project-ID.<p>
362     *
363     * For projectIds &ne; 0, the pattern {@link #QUERY_PROJECT_SEARCH_PATTERN} in table names of queries is
364     * replaced with "_ONLINE_" or "_OFFLINE_" to choose the right database
365     * tables for SQL queries that are project dependent!
366     *
367     * @param projectId the ID of the specified CmsProject
368     * @param queryKey the key of the SQL query
369     * @return the the SQL query in this property list with the specified key
370     */
371    public String readQuery(CmsUUID projectId, String queryKey) {
372
373        String key;
374        if ((projectId != null) && !projectId.isNullUUID()) {
375            // id 0 is special, please see below
376            StringBuffer buffer = new StringBuffer(128);
377            buffer.append(queryKey);
378            if (projectId.equals(CmsProject.ONLINE_PROJECT_ID)) {
379                buffer.append("_ONLINE");
380            } else {
381                buffer.append("_OFFLINE");
382            }
383            key = buffer.toString();
384        } else {
385            key = queryKey;
386        }
387
388        // look up the query in the cache
389        String query = m_cachedQueries.get(key);
390
391        if (query == null) {
392            // the query has not been cached yet
393            // get the SQL statement from the properties hash
394            query = readQuery(queryKey);
395
396            if (query == null) {
397                throw new CmsRuntimeException(Messages.get().container(Messages.ERR_QUERY_NOT_FOUND_1, queryKey));
398            }
399
400            // replace control chars.
401            query = CmsStringUtil.substitute(query, "\t", " ");
402            query = CmsStringUtil.substitute(query, "\n", " ");
403
404            if ((projectId != null) && !projectId.isNullUUID()) {
405                // a project ID = 0 is an internal indicator that a project-independent
406                // query was requested - further regex operations are not required then
407                query = CmsSqlManager.replaceProjectPattern(projectId, query);
408            }
409
410            // to minimize costs, all statements with replaced expressions are cached in a map
411            m_cachedQueries.put(key, query);
412        }
413
414        return query;
415    }
416
417    /**
418     * Searches for the SQL query with the specified key.<p>
419     *
420     * @param queryKey the SQL query key
421     * @return the the SQL query in this property list with the specified key
422     */
423    public String readQuery(String queryKey) {
424
425        String value = m_queries.get(queryKey);
426        if (value == null) {
427            if (LOG.isErrorEnabled()) {
428                LOG.error(Messages.get().getBundle().key(Messages.LOG_QUERY_NOT_FOUND_1, queryKey));
429            }
430        }
431        return value;
432    }
433
434    /**
435     * Sets the designated parameter to the given Java array of bytes.<p>
436     *
437     * The driver converts this to an SQL VARBINARY or LONGVARBINARY (depending on the argument's
438     * size relative to the driver's limits on VARBINARY values) when it sends it to the database.
439     *
440     * @param statement the PreparedStatement where the content is set
441     * @param pos the first parameter is 1, the second is 2, ...
442     * @param content the parameter value
443     * @throws SQLException if a database access error occurs
444     */
445    public void setBytes(PreparedStatement statement, int pos, byte[] content) throws SQLException {
446
447        if (content.length < 2000) {
448            statement.setBytes(pos, content);
449        } else {
450            statement.setBinaryStream(pos, new ByteArrayInputStream(content), content.length);
451        }
452    }
453
454    /**
455     * Replaces null or empty Strings with a String with one space character <code>" "</code>.<p>
456     *
457     * @param value the string to validate
458     * @return the validate string or a String with one space character if the validated string is null or empty
459     */
460    public String validateEmpty(String value) {
461
462        if (CmsStringUtil.isNotEmpty(value)) {
463            return value;
464        }
465
466        return " ";
467    }
468
469    /**
470     * Loads additional Java properties hashes containing SQL queries.<p>
471     *
472     * @param additionalQueryProperties the additional SQL query property files to load
473     */
474    protected void loadAdditionalQueryProperties(List<String> additionalQueryProperties) {
475
476        if (additionalQueryProperties == null) {
477            return;
478        }
479        for (String propertyFilename : additionalQueryProperties) {
480            if (CmsStringUtil.isNotEmptyOrWhitespaceOnly(propertyFilename)) {
481                loadQueryProperties(propertyFilename);
482            }
483        }
484    }
485
486    /**
487     * Loads a Java properties hash containing SQL queries.<p>
488     *
489     * @param propertyFilename the package/filename of the properties hash
490     */
491    protected void loadQueryProperties(String propertyFilename) {
492
493        Properties properties = new Properties();
494
495        try {
496            properties.load(getClass().getClassLoader().getResourceAsStream(propertyFilename));
497            m_queries.putAll(CmsCollectionsGenericWrapper.<String, String> map(properties));
498            replaceQuerySearchPatterns();
499        } catch (Throwable t) {
500            if (LOG.isErrorEnabled()) {
501                LOG.error(
502                    Messages.get().getBundle().key(Messages.LOG_LOAD_QUERY_PROP_FILE_FAILED_1, propertyFilename),
503                    t);
504            }
505
506            properties = null;
507        }
508    }
509
510    /**
511     * Replaces patterns ${XXX} by another property value, if XXX is a property key with a value.<p>
512     */
513    protected synchronized void replaceQuerySearchPatterns() {
514
515        String currentKey = null;
516        String currentValue = null;
517        int startIndex = 0;
518        int endIndex = 0;
519        int lastIndex = 0;
520
521        Iterator<String> allKeys = m_queries.keySet().iterator();
522        while (allKeys.hasNext()) {
523            currentKey = allKeys.next();
524            currentValue = m_queries.get(currentKey);
525            startIndex = 0;
526            endIndex = 0;
527            lastIndex = 0;
528
529            while ((startIndex = currentValue.indexOf("${", lastIndex)) != -1) {
530                endIndex = currentValue.indexOf('}', startIndex);
531                if ((endIndex != -1) && !currentValue.startsWith(QUERY_PROJECT_SEARCH_PATTERN, startIndex - 1)) {
532
533                    String replaceKey = currentValue.substring(startIndex + 2, endIndex);
534                    String searchPattern = currentValue.substring(startIndex, endIndex + 1);
535                    String replacePattern = this.readQuery(replaceKey);
536
537                    if (replacePattern != null) {
538                        currentValue = CmsStringUtil.substitute(currentValue, searchPattern, replacePattern);
539                    }
540                }
541
542                lastIndex = endIndex + 2;
543            }
544            m_queries.put(currentKey, currentValue);
545        }
546    }
547}