You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@velocity.apache.org by cb...@apache.org on 2026/04/22 13:27:57 UTC
(velocity-engine) branch velocity-989 updated (eecc69de -> a513c4f7)
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a change to branch velocity-989
in repository https://gitbox.apache.org/repos/asf/velocity-engine.git
discard eecc69de VELOCITY-989 Introduce PreparedStatementsFactory SPI and fix connection leaks
new a513c4f7 Deprecate DatabaseObjectsFactory in favor of PreparedStatementsFactory ; fix connection leaks on failures
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (eecc69de)
\
N -- N -- N refs/heads/velocity-989 (a513c4f7)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
(velocity-engine) 01/01: Deprecate DatabaseObjectsFactory in favor of PreparedStatementsFactory ; fix connection leaks on failures
Posted by cb...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch velocity-989
in repository https://gitbox.apache.org/repos/asf/velocity-engine.git
commit a513c4f77ff3b8c6681c5d02e3c0752e9bad891e
Author: Claude Brisson <cl...@renegat.net>
AuthorDate: Wed Apr 22 14:50:35 2026 +0200
Deprecate DatabaseObjectsFactory in favor of PreparedStatementsFactory ; fix connection leaks on failures
---
.../resource/loader/DataSourceResourceLoader.java | 147 +++++++++++++++------
.../resource/loader/DatabaseObjectsFactory.java | 29 +++-
.../loader/DefaultDatabaseObjectsFactory.java | 136 ++++++++++++++++++-
.../loader/DefaultPreparedStatementsFactory.java | 65 +++++++++
.../resource/loader/PreparedStatementsFactory.java | 90 +++++++++++++
.../velocity/test/sql/CountingDataSource.java | 106 +++++++++++++++
...aSourceResourceLoaderFactoryConfigTestCase.java | 143 ++++++++++++++++++++
.../sql/DataSourceResourceLoaderLeakTestCase.java | 110 +++++++++++++++
.../test/sql/SentinelDatabaseObjectsFactory.java | 45 +++++++
.../sql/SentinelPreparedStatementsFactory.java | 44 ++++++
10 files changed, 865 insertions(+), 50 deletions(-)
diff --git a/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DataSourceResourceLoader.java b/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DataSourceResourceLoader.java
index 4dbc071d..9fb381a7 100644
--- a/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DataSourceResourceLoader.java
+++ b/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DataSourceResourceLoader.java
@@ -120,13 +120,16 @@
* );
* </code></pre>
* <p>Prior to Velocity 2.4, this class should not be considered thread-safe.</p>
- * <p>Since Velocity 2.4, the handling of JDBC connections and prepared statements is delegated to the
- * {@link org.apache.velocity.runtime.resource.loader.DatabaseObjectsFactory} instance. The default class for this
- * database objects factory is {@link org.apache.velocity.runtime.resource.loader.DefaultDatabaseObjectsFactory},
- * which obtains a new connection from the data source and prepares statements at each query. Connection pooling
- * and statement caching, if needed, are the responsibility of the configured {@link javax.sql.DataSource} / JDBC
- * driver; users who need them should configure their pool (DBCP2, Tomcat JDBC Pool, HikariCP, etc.) accordingly,
- * or provide a custom {@link org.apache.velocity.runtime.resource.loader.DatabaseObjectsFactory} implementation.</p>
+ * <p>Since Velocity 2.5, the handling of JDBC connections and prepared statements is delegated to a
+ * {@link org.apache.velocity.runtime.resource.loader.PreparedStatementsFactory} instance (the older
+ * {@link org.apache.velocity.runtime.resource.loader.DatabaseObjectsFactory} remains supported but is
+ * deprecated). The default class is
+ * {@link org.apache.velocity.runtime.resource.loader.DefaultPreparedStatementsFactory}, which obtains a
+ * new connection from the data source and prepares a statement at each query. Connection pooling and
+ * statement caching, if needed, are the responsibility of the configured {@link javax.sql.DataSource} /
+ * JDBC driver; users who need them should configure their pool (DBCP2, Tomcat JDBC Pool, HikariCP, etc.)
+ * accordingly, or provide a custom
+ * {@link org.apache.velocity.runtime.resource.loader.PreparedStatementsFactory} implementation.</p>
*
* @author <a href="mailto:wglass@forio.com">Will Glass-Husain</a>
* @author <a href="mailto:matt@raibledesigns.com">Matt Raible</a>
@@ -139,23 +142,21 @@
*/
public class DataSourceResourceLoader extends ResourceLoader
{
- private static final String DATABASE_OBJECTS_FACTORY_DEFAULT_CLASS = "org.apache.velocity.runtime.resource.loader.DefaultDatabaseObjectsFactory";
-
private DataSource dataSource;
- private DatabaseObjectsFactory factory;
+ private PreparedStatementsFactory factory;
private String templateColumn;
private String timestampColumn;
private String templateSQL;
private String timestampSQL;
- private class SelfCleaningReader extends FilterReader
+ private static class SelfCleaningReader extends FilterReader
{
- private final ResultSet resultSet;
+ private final PreparedStatementsFactory.StatementHolder holder;
- public SelfCleaningReader(Reader reader, ResultSet resultSet)
+ SelfCleaningReader(Reader reader, PreparedStatementsFactory.StatementHolder holder)
{
super(reader);
- this.resultSet = resultSet;
+ this.holder = holder;
}
@Override
@@ -164,9 +165,7 @@ public void close() throws IOException
super.close();
try
{
- PreparedStatement statement = (PreparedStatement) resultSet.getStatement();
- resultSet.close();
- factory.releaseStatement(templateSQL, statement);
+ holder.close();
}
catch (RuntimeException re)
{
@@ -224,25 +223,78 @@ else if (dataSourceName != null)
throw new RuntimeException(msg);
}
- String factoryClassName = configuration.getString("database_objects_factory.class");
- if (factoryClassName == null)
+ try
{
- factoryClassName = DATABASE_OBJECTS_FACTORY_DEFAULT_CLASS;
+ factory = resolveFactory(configuration);
}
- try
+ catch (VelocityException ve)
{
- Class<?> factoryClass = ClassUtils.getClass(factoryClassName);
- factory = (DatabaseObjectsFactory) factoryClass.getDeclaredConstructor().newInstance();
- factory.init(dataSource, configuration.subset("database_objects_factory"));
+ throw ve;
}
catch (Exception e)
{
- throw new VelocityException("could not find database objects factory class", e);
+ throw new VelocityException("could not instantiate prepared statements factory", e);
}
log.trace("DataSourceResourceLoader initialized.");
}
+ /**
+ * Resolve the configured {@link PreparedStatementsFactory}, honouring (in order of
+ * precedence): {@code prepared_statements_factory.instance},
+ * {@code prepared_statements_factory.class}, deprecated
+ * {@code database_objects_factory.instance}, deprecated
+ * {@code database_objects_factory.class}, then the default.
+ */
+ private PreparedStatementsFactory resolveFactory(ExtProperties configuration) throws Exception
+ {
+ Object newInstance = configuration.getProperty("prepared_statements_factory.instance");
+ if (newInstance != null)
+ {
+ if (!(newInstance instanceof PreparedStatementsFactory))
+ {
+ throw new VelocityException("prepared_statements_factory.instance is not a PreparedStatementsFactory");
+ }
+ PreparedStatementsFactory f = (PreparedStatementsFactory) newInstance;
+ f.init(dataSource, configuration.subset("prepared_statements_factory"));
+ return f;
+ }
+
+ String newClassName = configuration.getString("prepared_statements_factory.class");
+ if (newClassName != null)
+ {
+ PreparedStatementsFactory f = (PreparedStatementsFactory)
+ ClassUtils.getClass(newClassName).getDeclaredConstructor().newInstance();
+ f.init(dataSource, configuration.subset("prepared_statements_factory"));
+ return f;
+ }
+
+ Object oldInstance = configuration.getProperty("database_objects_factory.instance");
+ if (oldInstance != null)
+ {
+ if (!(oldInstance instanceof DatabaseObjectsFactory))
+ {
+ throw new VelocityException("database_objects_factory.instance is not a DatabaseObjectsFactory");
+ }
+ DatabaseObjectsFactory legacy = (DatabaseObjectsFactory) oldInstance;
+ legacy.init(dataSource, configuration.subset("database_objects_factory"));
+ return new DefaultDatabaseObjectsFactory.LegacyAdapter(legacy);
+ }
+
+ String oldClassName = configuration.getString("database_objects_factory.class");
+ if (oldClassName != null)
+ {
+ DatabaseObjectsFactory legacy = (DatabaseObjectsFactory)
+ ClassUtils.getClass(oldClassName).getDeclaredConstructor().newInstance();
+ legacy.init(dataSource, configuration.subset("database_objects_factory"));
+ return new DefaultDatabaseObjectsFactory.LegacyAdapter(legacy);
+ }
+
+ PreparedStatementsFactory f = new DefaultPreparedStatementsFactory();
+ f.init(dataSource, configuration.subset("prepared_statements_factory"));
+ return f;
+ }
+
/**
* Set the DataSource used by this resource loader. Call this as an alternative to
* specifying the data source name via properties.
@@ -295,11 +347,13 @@ public synchronized Reader getResourceReader(final String name, String encoding)
throw new ResourceNotFoundException("DataSourceResourceLoader: Template name was empty or null");
}
+ Reader out = null;
+ PreparedStatementsFactory.StatementHolder holder = null;
ResultSet rs = null;
try
{
- PreparedStatement statement = factory.prepareStatement(templateSQL);
- rs = fetchResult(statement, name);
+ holder = factory.prepare(templateSQL);
+ rs = fetchResult(holder.getStatement(), name);
if (rs.next())
{
@@ -310,7 +364,8 @@ public synchronized Reader getResourceReader(final String name, String encoding)
+ "template column for '"
+ name + "' is null");
}
- return new SelfCleaningReader(reader, rs);
+ out = new SelfCleaningReader(reader, holder);
+ return out;
}
else
{
@@ -328,6 +383,24 @@ public synchronized Reader getResourceReader(final String name, String encoding)
log.error(msg, e);
throw new ResourceNotFoundException(msg);
}
+ finally
+ {
+ if (out == null)
+ {
+ closeResultSet(rs);
+ if (holder != null)
+ {
+ try
+ {
+ holder.close();
+ }
+ catch (SQLException sqle)
+ {
+ log.debug("DataSourceResourceLoader: error releasing prepared statement", sqle);
+ }
+ }
+ }
+ }
}
/**
@@ -352,12 +425,10 @@ private long readLastModified(final Resource resource, final String operation)
}
else
{
- PreparedStatement statement = null;
ResultSet rs = null;
- try
+ try (PreparedStatementsFactory.StatementHolder holder = factory.prepare(timestampSQL))
{
- statement = factory.prepareStatement(timestampSQL);
- rs = fetchResult(statement, name);
+ rs = fetchResult(holder.getStatement(), name);
if (rs.next())
{
@@ -382,18 +453,6 @@ private long readLastModified(final Resource resource, final String operation)
finally
{
closeResultSet(rs);
- if (statement != null)
- {
- try
- {
- factory.releaseStatement(timestampSQL, statement);
- }
- catch (SQLException sqle)
- {
- // just log, don't throw
- log.debug("DataSourceResourceLoader: error releasing prepared statement", sqle);
- }
- }
}
}
return timeStamp;
diff --git a/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DatabaseObjectsFactory.java b/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DatabaseObjectsFactory.java
index 39eac7e7..5f3be61a 100644
--- a/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DatabaseObjectsFactory.java
+++ b/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DatabaseObjectsFactory.java
@@ -1,5 +1,24 @@
package org.apache.velocity.runtime.resource.loader;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
import org.apache.velocity.util.ExtProperties;
import org.slf4j.Logger;
@@ -8,9 +27,15 @@
import java.sql.SQLException;
/**
- * Factory for creating connections and prepared statements
+ * Factory for creating connections and prepared statements.
+ *
+ * @deprecated Since 2.5, use {@link PreparedStatementsFactory} instead. This
+ * interface relies on recovering the connection from the statement
+ * via {@link PreparedStatement#getConnection()}, which is not
+ * portable across JDBC pool implementations. See
+ * {@link DataSourceResourceLoader} for the supported configuration.
*/
-
+@Deprecated
public interface DatabaseObjectsFactory {
/**
diff --git a/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DefaultDatabaseObjectsFactory.java b/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DefaultDatabaseObjectsFactory.java
index 6edc6124..2a94626f 100644
--- a/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DefaultDatabaseObjectsFactory.java
+++ b/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DefaultDatabaseObjectsFactory.java
@@ -1,17 +1,51 @@
package org.apache.velocity.runtime.resource.loader;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
import org.apache.velocity.util.ExtProperties;
+import org.slf4j.Logger;
import javax.sql.DataSource;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
/**
- * Database objects factory which will obtain a new connection from the data source and prepare needed statements
- * at each call
+ * Database objects factory which obtains a new connection from the data source and
+ * prepares a statement at each call.
+ *
+ * <p>The returned {@link PreparedStatement} is a proxy that remembers the connection
+ * it was created from and returns it via {@link PreparedStatement#getConnection()}.
+ * This keeps the {@link #releaseStatement(String, PreparedStatement)} contract
+ * correct across JDBC pool implementations — in particular Tomcat JDBC Pool, whose
+ * default facade returns the unwrapped driver connection and would bypass the pool
+ * wrapper on close.</p>
+ *
+ * @deprecated Since 2.5, use {@link DefaultPreparedStatementsFactory} via the
+ * {@link PreparedStatementsFactory} SPI.
*/
-
+@Deprecated
public class DefaultDatabaseObjectsFactory implements DatabaseObjectsFactory {
private DataSource dataSource;
@@ -35,7 +69,16 @@ public void init(DataSource dataSource, ExtProperties properties)
public PreparedStatement prepareStatement(String sql) throws SQLException
{
Connection connection = dataSource.getConnection();
- return connection.prepareStatement(sql);
+ try
+ {
+ PreparedStatement statement = connection.prepareStatement(sql);
+ return TrackedPreparedStatement.wrap(statement, connection);
+ }
+ catch (SQLException | RuntimeException e)
+ {
+ try { connection.close(); } catch (SQLException ignored) {}
+ throw e;
+ }
}
/**
@@ -56,4 +99,89 @@ public void releaseStatement(String sql, PreparedStatement stmt) throws SQLExcep
connection.close();
}
}
+
+ /**
+ * Dynamic-proxy {@link PreparedStatement} wrapper that overrides
+ * {@link PreparedStatement#getConnection()} to return the connection the
+ * statement was created from, rather than whatever the underlying driver or
+ * pool happens to expose. Kept alive only to make the deprecated
+ * {@link DatabaseObjectsFactory} contract correct across pools.
+ */
+ private static final class TrackedPreparedStatement implements InvocationHandler
+ {
+ private final PreparedStatement delegate;
+ private final Connection connection;
+
+ private TrackedPreparedStatement(PreparedStatement delegate, Connection connection)
+ {
+ this.delegate = delegate;
+ this.connection = connection;
+ }
+
+ static PreparedStatement wrap(PreparedStatement delegate, Connection connection)
+ {
+ return (PreparedStatement) Proxy.newProxyInstance(
+ TrackedPreparedStatement.class.getClassLoader(),
+ new Class<?>[] { PreparedStatement.class },
+ new TrackedPreparedStatement(delegate, connection));
+ }
+
+ @Override
+ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
+ {
+ if ("getConnection".equals(method.getName()) && method.getParameterCount() == 0)
+ {
+ return connection;
+ }
+ try
+ {
+ return method.invoke(delegate, args);
+ }
+ catch (InvocationTargetException ite)
+ {
+ throw ite.getCause();
+ }
+ }
+ }
+
+ /**
+ * Adapts any {@link DatabaseObjectsFactory} (including user subclasses) to the
+ * new {@link PreparedStatementsFactory} SPI. Used by {@link DataSourceResourceLoader}
+ * when a legacy factory is configured. Gone with the deprecated interface in the
+ * next major.
+ */
+ static final class LegacyAdapter implements PreparedStatementsFactory
+ {
+ private final DatabaseObjectsFactory legacy;
+
+ LegacyAdapter(DatabaseObjectsFactory legacy)
+ {
+ this.legacy = legacy;
+ }
+
+ @Override
+ public void init(DataSource dataSource, ExtProperties properties) throws SQLException
+ {
+ legacy.init(dataSource, properties);
+ }
+
+ @Override
+ public void setLogger(Logger log)
+ {
+ legacy.setLogger(log);
+ }
+
+ @Override
+ public PreparedStatementsFactory.StatementHolder prepare(String sql) throws SQLException
+ {
+ PreparedStatement statement = legacy.prepareStatement(sql);
+ return new PreparedStatementsFactory.StatementHolder(statement, () -> legacy.releaseStatement(sql, statement));
+ }
+
+ @Override
+ public void clear()
+ {
+ legacy.clear();
+ }
+ }
}
diff --git a/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DefaultPreparedStatementsFactory.java b/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DefaultPreparedStatementsFactory.java
new file mode 100644
index 00000000..673f0eca
--- /dev/null
+++ b/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/DefaultPreparedStatementsFactory.java
@@ -0,0 +1,65 @@
+package org.apache.velocity.runtime.resource.loader;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import org.apache.velocity.util.ExtProperties;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+
+/**
+ * Stock {@link PreparedStatementsFactory}: one fresh connection per {@link #prepare(String)},
+ * closed with the holder. Pooling and statement caching, if needed, belong to the configured
+ * {@link DataSource} / JDBC driver.
+ *
+ * @since 2.5
+ */
+public class DefaultPreparedStatementsFactory implements PreparedStatementsFactory
+{
+ private DataSource dataSource;
+
+ @Override
+ public void init(DataSource dataSource, ExtProperties properties)
+ {
+ this.dataSource = dataSource;
+ }
+
+ @Override
+ public PreparedStatementsFactory.StatementHolder prepare(String sql) throws SQLException
+ {
+ Connection connection = dataSource.getConnection();
+ try
+ {
+ PreparedStatement statement = connection.prepareStatement(sql);
+ return new PreparedStatementsFactory.StatementHolder(statement, () ->
+ {
+ try { statement.close(); }
+ finally { connection.close(); }
+ });
+ }
+ catch (SQLException | RuntimeException e)
+ {
+ try { connection.close(); } catch (SQLException ignored) {}
+ throw e;
+ }
+ }
+}
diff --git a/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/PreparedStatementsFactory.java b/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/PreparedStatementsFactory.java
new file mode 100644
index 00000000..8305f41d
--- /dev/null
+++ b/velocity-engine-core/src/main/java/org/apache/velocity/runtime/resource/loader/PreparedStatementsFactory.java
@@ -0,0 +1,90 @@
+package org.apache.velocity.runtime.resource.loader;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import org.apache.velocity.util.ExtProperties;
+import org.slf4j.Logger;
+
+import javax.sql.DataSource;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+
+/**
+ * SPI for {@link DataSourceResourceLoader}, replacing the deprecated
+ * {@link DatabaseObjectsFactory}. An implementation decides how statements are
+ * prepared and what {@link StatementHolder#close()} does.
+ *
+ * @since 2.5
+ */
+public interface PreparedStatementsFactory
+{
+ void init(DataSource dataSource, ExtProperties properties) throws SQLException;
+
+ default void setLogger(Logger log) {}
+
+ StatementHolder prepare(String sql) throws SQLException;
+
+ /** Equivalent to {@link StatementHolder#close()}; kept for callers that prefer an explicit idiom. */
+ default void release(StatementHolder holder) throws SQLException
+ {
+ holder.close();
+ }
+
+ default void clear() {}
+
+ /**
+ * A prepared statement bundled with its release action. Implementations of
+ * {@link PreparedStatementsFactory#prepare(String)} supply the action via any
+ * {@link AutoCloseable}: close the connection, return to a cache, etc.
+ */
+ final class StatementHolder implements AutoCloseable
+ {
+ private final PreparedStatement statement;
+ private final AutoCloseable onClose;
+
+ public StatementHolder(PreparedStatement statement, AutoCloseable onClose)
+ {
+ this.statement = statement;
+ this.onClose = onClose;
+ }
+
+ public PreparedStatement getStatement()
+ {
+ return statement;
+ }
+
+ @Override
+ public void close() throws SQLException
+ {
+ try
+ {
+ onClose.close();
+ }
+ catch (SQLException | RuntimeException e)
+ {
+ throw e;
+ }
+ catch (Exception e)
+ {
+ throw new SQLException("release action failed", e);
+ }
+ }
+ }
+}
diff --git a/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/CountingDataSource.java b/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/CountingDataSource.java
new file mode 100644
index 00000000..f75ab23d
--- /dev/null
+++ b/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/CountingDataSource.java
@@ -0,0 +1,106 @@
+package org.apache.velocity.test.sql;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import javax.sql.DataSource;
+import java.io.PrintWriter;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Logger;
+
+/**
+ * DataSource that wraps another and tracks how many connections it currently has
+ * handed out but not yet closed. Used by leak tests to assert the count returns
+ * to zero after each call path.
+ */
+public class CountingDataSource implements DataSource
+{
+ private final DataSource delegate;
+ private final AtomicInteger active = new AtomicInteger();
+
+ public CountingDataSource(DataSource delegate)
+ {
+ this.delegate = delegate;
+ }
+
+ public int getActiveCount()
+ {
+ return active.get();
+ }
+
+ @Override
+ public Connection getConnection() throws SQLException
+ {
+ return track(delegate.getConnection());
+ }
+
+ @Override
+ public Connection getConnection(String username, String password) throws SQLException
+ {
+ return track(delegate.getConnection(username, password));
+ }
+
+ private Connection track(Connection real)
+ {
+ active.incrementAndGet();
+ return (Connection) Proxy.newProxyInstance(
+ CountingDataSource.class.getClassLoader(),
+ new Class<?>[] { Connection.class },
+ new InvocationHandler()
+ {
+ private boolean closed = false;
+
+ @Override
+ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
+ {
+ if ("close".equals(method.getName()) && method.getParameterCount() == 0)
+ {
+ if (!closed)
+ {
+ closed = true;
+ try { real.close(); }
+ finally { active.decrementAndGet(); }
+ }
+ return null;
+ }
+ if ("isClosed".equals(method.getName()) && method.getParameterCount() == 0)
+ {
+ return closed || (Boolean) method.invoke(real, args);
+ }
+ try { return method.invoke(real, args); }
+ catch (InvocationTargetException ite) { throw ite.getCause(); }
+ }
+ });
+ }
+
+ @Override public PrintWriter getLogWriter() { return null; }
+ @Override public int getLoginTimeout() { return 0; }
+ @Override public void setLogWriter(PrintWriter out) {}
+ @Override public void setLoginTimeout(int seconds) {}
+ @Override public boolean isWrapperFor(Class<?> iface) { return false; }
+ @Override public <T> T unwrap(Class<T> iface) throws SQLException { throw new SQLException("Not implemented"); }
+ @Override public Logger getParentLogger() throws SQLFeatureNotSupportedException { throw new SQLFeatureNotSupportedException(); }
+}
diff --git a/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/DataSourceResourceLoaderFactoryConfigTestCase.java b/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/DataSourceResourceLoaderFactoryConfigTestCase.java
new file mode 100644
index 00000000..dec5e60f
--- /dev/null
+++ b/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/DataSourceResourceLoaderFactoryConfigTestCase.java
@@ -0,0 +1,143 @@
+package org.apache.velocity.test.sql;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import junit.framework.Test;
+import junit.framework.TestSuite;
+import org.apache.velocity.app.Velocity;
+import org.apache.velocity.runtime.RuntimeInstance;
+import org.apache.velocity.runtime.resource.loader.DataSourceResourceLoader;
+import org.apache.velocity.test.misc.TestLogger;
+import org.apache.velocity.util.ExtProperties;
+
+import javax.sql.DataSource;
+
+/**
+ * Verifies the factory-resolution precedence in {@link DataSourceResourceLoader#init}:
+ * prepared_statements_factory.instance {@literal >} prepared_statements_factory.class
+ * {@literal >} deprecated database_objects_factory.instance
+ * {@literal >} deprecated database_objects_factory.class {@literal >} default.
+ */
+public class DataSourceResourceLoaderFactoryConfigTestCase extends BaseSQLTest
+{
+ private static final String DATA_PATH = TEST_COMPARE_DIR + "/ds";
+
+ public DataSourceResourceLoaderFactoryConfigTestCase(String name) throws Exception
+ {
+ super(name, DATA_PATH);
+ }
+
+ public static Test suite()
+ {
+ return new TestSuite(DataSourceResourceLoaderFactoryConfigTestCase.class);
+ }
+
+ @Override
+ public void setUp()
+ {
+ SentinelPreparedStatementsFactory.reset();
+ SentinelDatabaseObjectsFactory.reset();
+ }
+
+ public void testNewInstanceIsUsed() throws Exception
+ {
+ ExtProperties props = baseProps();
+ props.setProperty("resource.loader.ds.prepared_statements_factory.instance",
+ new SentinelPreparedStatementsFactory());
+ loadTemplate(props);
+ assertTrue("new-SPI sentinel should have been called",
+ SentinelPreparedStatementsFactory.calls.get() > 0);
+ }
+
+ public void testNewClassIsUsed() throws Exception
+ {
+ ExtProperties props = baseProps();
+ props.setProperty("resource.loader.ds.prepared_statements_factory.class",
+ SentinelPreparedStatementsFactory.class.getName());
+ loadTemplate(props);
+ assertTrue("new-SPI sentinel should have been called",
+ SentinelPreparedStatementsFactory.calls.get() > 0);
+ }
+
+ public void testLegacyInstanceIsUsed() throws Exception
+ {
+ ExtProperties props = baseProps();
+ props.setProperty("resource.loader.ds.database_objects_factory.instance",
+ new SentinelDatabaseObjectsFactory());
+ loadTemplate(props);
+ assertTrue("legacy-SPI sentinel should have been called",
+ SentinelDatabaseObjectsFactory.calls.get() > 0);
+ }
+
+ public void testLegacyClassIsUsed() throws Exception
+ {
+ ExtProperties props = baseProps();
+ props.setProperty("resource.loader.ds.database_objects_factory.class",
+ SentinelDatabaseObjectsFactory.class.getName());
+ loadTemplate(props);
+ assertTrue("legacy-SPI sentinel should have been called",
+ SentinelDatabaseObjectsFactory.calls.get() > 0);
+ }
+
+ public void testDefaultIsUsedWhenNothingConfigured() throws Exception
+ {
+ loadTemplate(baseProps());
+ assertEquals("no sentinel should be used", 0, SentinelPreparedStatementsFactory.calls.get());
+ assertEquals("no sentinel should be used", 0, SentinelDatabaseObjectsFactory.calls.get());
+ }
+
+ public void testNewClassTakesPrecedenceOverLegacyClass() throws Exception
+ {
+ ExtProperties props = baseProps();
+ props.setProperty("resource.loader.ds.prepared_statements_factory.class",
+ SentinelPreparedStatementsFactory.class.getName());
+ props.setProperty("resource.loader.ds.database_objects_factory.class",
+ SentinelDatabaseObjectsFactory.class.getName());
+ loadTemplate(props);
+ assertTrue("new-SPI sentinel should be used", SentinelPreparedStatementsFactory.calls.get() > 0);
+ assertEquals("legacy-SPI sentinel should NOT be used", 0, SentinelDatabaseObjectsFactory.calls.get());
+ }
+
+ private ExtProperties baseProps() throws Exception
+ {
+ DataSource ds = new TestDataSource(TEST_JDBC_DRIVER_CLASS, TEST_JDBC_URI, TEST_JDBC_LOGIN, TEST_JDBC_PASSWORD);
+ DataSourceResourceLoader rl = new DataSourceResourceLoader();
+ rl.setDataSource(ds);
+
+ ExtProperties props = new ExtProperties();
+ props.addProperty("resource.loader", "ds");
+ props.setProperty("resource.loader.ds.instance", rl);
+ props.setProperty("resource.loader.ds.resource.table", "velocity_template_varchar");
+ props.setProperty("resource.loader.ds.resource.key_column", "vt_id");
+ props.setProperty("resource.loader.ds.resource.template_column", "vt_def");
+ props.setProperty("resource.loader.ds.resource.timestamp_column", "vt_timestamp");
+ props.setProperty("resource.loader.ds.cache", "false");
+ props.setProperty(Velocity.RUNTIME_LOG_INSTANCE, new TestLogger(false, false));
+ return props;
+ }
+
+ private void loadTemplate(ExtProperties props)
+ {
+ RuntimeInstance engine = new RuntimeInstance();
+ engine.setConfiguration(props);
+ engine.init();
+ engine.getTemplate("testTemplate1");
+ }
+}
diff --git a/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/DataSourceResourceLoaderLeakTestCase.java b/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/DataSourceResourceLoaderLeakTestCase.java
new file mode 100644
index 00000000..a39d46d9
--- /dev/null
+++ b/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/DataSourceResourceLoaderLeakTestCase.java
@@ -0,0 +1,110 @@
+package org.apache.velocity.test.sql;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import junit.framework.Test;
+import junit.framework.TestSuite;
+import org.apache.velocity.Template;
+import org.apache.velocity.VelocityContext;
+import org.apache.velocity.app.Velocity;
+import org.apache.velocity.exception.ResourceNotFoundException;
+import org.apache.velocity.runtime.RuntimeInstance;
+import org.apache.velocity.runtime.resource.loader.DataSourceResourceLoader;
+import org.apache.velocity.test.misc.TestLogger;
+import org.apache.velocity.util.ExtProperties;
+
+import java.io.StringWriter;
+
+/**
+ * Asserts that DataSourceResourceLoader never leaks connections — the counting
+ * DataSource's active count must return to zero after every call path, including
+ * the failure paths that previously leaked (template-not-found, SQL failures).
+ */
+public class DataSourceResourceLoaderLeakTestCase extends BaseSQLTest
+{
+ private static final String DATA_PATH = TEST_COMPARE_DIR + "/ds";
+
+ private CountingDataSource ds;
+ private RuntimeInstance engine;
+
+ public DataSourceResourceLoaderLeakTestCase(String name) throws Exception
+ {
+ super(name, DATA_PATH);
+ }
+
+ public static Test suite()
+ {
+ return new TestSuite(DataSourceResourceLoaderLeakTestCase.class);
+ }
+
+ @Override
+ public void setUp() throws Exception
+ {
+ ds = new CountingDataSource(
+ new TestDataSource(TEST_JDBC_DRIVER_CLASS, TEST_JDBC_URI, TEST_JDBC_LOGIN, TEST_JDBC_PASSWORD));
+
+ DataSourceResourceLoader rl = new DataSourceResourceLoader();
+ rl.setDataSource(ds);
+
+ ExtProperties props = new ExtProperties();
+ props.addProperty("resource.loader", "ds");
+ props.setProperty("resource.loader.ds.instance", rl);
+ props.setProperty("resource.loader.ds.resource.table", "velocity_template_varchar");
+ props.setProperty("resource.loader.ds.resource.key_column", "vt_id");
+ props.setProperty("resource.loader.ds.resource.template_column", "vt_def");
+ props.setProperty("resource.loader.ds.resource.timestamp_column", "vt_timestamp");
+ props.setProperty("resource.loader.ds.cache", "false");
+ props.setProperty(Velocity.RUNTIME_LOG_INSTANCE, new TestLogger(false, false));
+
+ engine = new RuntimeInstance();
+ engine.setConfiguration(props);
+ engine.init();
+ }
+
+ public void testSuccessfulLoadDoesNotLeak() throws Exception
+ {
+ Template t = engine.getTemplate("testTemplate1");
+ StringWriter w = new StringWriter();
+ t.merge(new VelocityContext(), w);
+ w.close();
+ assertEquals("active connections after successful merge", 0, ds.getActiveCount());
+ }
+
+ public void testMissingTemplateDoesNotLeak()
+ {
+ try
+ {
+ engine.getTemplate("does-not-exist");
+ fail("expected ResourceNotFoundException");
+ }
+ catch (ResourceNotFoundException expected)
+ {
+ }
+ assertEquals("active connections after template-not-found", 0, ds.getActiveCount());
+ }
+
+ public void testIsSourceModifiedDoesNotLeak() throws Exception
+ {
+ Template t = engine.getTemplate("testTemplate1");
+ int baseline = ds.getActiveCount();
+ assertFalse("should still be current", t.getResourceLoader().isSourceModified(t));
+ assertEquals("active connections after isSourceModified", baseline, ds.getActiveCount());
+ }
+}
diff --git a/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/SentinelDatabaseObjectsFactory.java b/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/SentinelDatabaseObjectsFactory.java
new file mode 100644
index 00000000..b328615a
--- /dev/null
+++ b/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/SentinelDatabaseObjectsFactory.java
@@ -0,0 +1,45 @@
+package org.apache.velocity.test.sql;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import org.apache.velocity.runtime.resource.loader.DefaultDatabaseObjectsFactory;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/** Test sentinel that records prepareStatement() calls on the deprecated SPI. */
+@SuppressWarnings("deprecation")
+public class SentinelDatabaseObjectsFactory extends DefaultDatabaseObjectsFactory
+{
+ public static final AtomicInteger calls = new AtomicInteger();
+
+ public static void reset()
+ {
+ calls.set(0);
+ }
+
+ @Override
+ public PreparedStatement prepareStatement(String sql) throws SQLException
+ {
+ calls.incrementAndGet();
+ return super.prepareStatement(sql);
+ }
+}
diff --git a/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/SentinelPreparedStatementsFactory.java b/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/SentinelPreparedStatementsFactory.java
new file mode 100644
index 00000000..21abe2a3
--- /dev/null
+++ b/velocity-engine-core/src/test/java/org/apache/velocity/test/sql/SentinelPreparedStatementsFactory.java
@@ -0,0 +1,44 @@
+package org.apache.velocity.test.sql;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import org.apache.velocity.runtime.resource.loader.DefaultPreparedStatementsFactory;
+import org.apache.velocity.runtime.resource.loader.PreparedStatementsFactory;
+
+import java.sql.SQLException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/** Test sentinel that records prepare() calls; used to verify which factory the loader picked up. */
+public class SentinelPreparedStatementsFactory extends DefaultPreparedStatementsFactory
+{
+ public static final AtomicInteger calls = new AtomicInteger();
+
+ public static void reset()
+ {
+ calls.set(0);
+ }
+
+ @Override
+ public PreparedStatementsFactory.StatementHolder prepare(String sql) throws SQLException
+ {
+ calls.incrementAndGet();
+ return super.prepare(sql);
+ }
+}