org.firebirdsql.jdbc.ClientInfoProvider Maven / Gradle / Ivy
Show all versions of jaybird Show documentation
/*
* Firebird Open Source JDBC Driver
*
* Distributable under LGPL license.
* You may obtain a copy of the License at http://www.gnu.org/copyleft/lgpl.html
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* LGPL License for more details.
*
* This file was created by members of the firebird development team.
* All individual contributions remain the Copyright (C) of those
* individuals. Contributors to this file are either listed here or
* can be obtained from a source control history command.
*
* All rights reserved.
*/
package org.firebirdsql.jdbc;
import org.firebirdsql.gds.ISCConstants;
import org.firebirdsql.jdbc.InternalTransactionCoordinator.MetaDataTransactionCoordinator;
import java.sql.ClientInfoStatus;
import java.sql.ResultSet;
import java.sql.SQLClientInfoException;
import java.sql.SQLException;
import java.sql.SQLNonTransientException;
import java.sql.Statement;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static java.util.Objects.requireNonNull;
import static java.util.Objects.requireNonNullElse;
import static java.util.stream.Collectors.toUnmodifiableSet;
import static org.firebirdsql.util.FirebirdSupportInfo.supportInfoFor;
/**
* Implementation for retrieving client info properties for a connection.
*
* @author Mark Rotteveel
* @since 6
*/
final class ClientInfoProvider {
private static final String USER_SESSION = "USER_SESSION";
private static final String USER_TRANSACTION = "USER_TRANSACTION";
private static final String SYSTEM = "SYSTEM";
private static final Set SUPPORTED_CONTEXTS = Set.of(USER_SESSION, USER_TRANSACTION, SYSTEM);
private static final String APPLICATION_NAME = "ApplicationName";
private static final ClientInfoProperty APPLICATION_NAME_PROP =
new ClientInfoProperty(APPLICATION_NAME, USER_SESSION);
private static final ClientInfoProperty APPLICATION_NAME_FALLBACK_PROP =
new ClientInfoProperty("CLIENT_PROCESS", SYSTEM);
private static final Set DEFAULT_CLIENT_INFO_PROPERTIES = Set.of(
APPLICATION_NAME_PROP,
new ClientInfoProperty("ClientUser", USER_SESSION),
new ClientInfoProperty("ClientHostname", USER_SESSION));
private static final Set DEFAULT_CLIENT_INFO_PROPERTY_NAMES = DEFAULT_CLIENT_INFO_PROPERTIES.stream()
.map(Object::toString)
.collect(toUnmodifiableSet());
private final FBConnection connection;
// if null, use DEFAULT_CLIENT_INFO_PROPERTIES
private Set knownProperties;
// Statement used for setting or retrieving client info properties.
// We don't try to close this statement, and rely on it getting closed by connection close
private Statement statement;
ClientInfoProvider(FBConnection connection) throws SQLException {
connection.checkValidity();
if (!supportInfoFor(connection).supportsGetSetContext()) {
throw new FBDriverNotCapableException(
"Required functionality (RDB$SET_CONTEXT()) only available in Firebird 2.0 or higher");
}
this.connection = connection;
}
private Statement getStatement() throws SQLException {
Statement statement = this.statement;
if (statement != null && !statement.isClosed()) return statement;
var metaDataTransactionCoordinator = new MetaDataTransactionCoordinator(connection.txCoordinator);
// Create statement which piggybacks on active transaction, starts one when needed, but does not commit (not
// even in auto-commit)
var rsBehavior = ResultSetBehavior.of(
ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY, ResultSet.CLOSE_CURSORS_AT_COMMIT);
return this.statement = new FBStatement(connection, rsBehavior, metaDataTransactionCoordinator);
}
/**
* Reset the currently known properties of this connection back to the default
*
* This only clears the list held by this instance, it does not remove or reset values on the server.
*
*/
void resetKnownProperties() {
knownProperties = null;
}
/**
* @return gets the known properties of this instance (it may be unmodifiable by returning the global default)
*/
// default access for tests
Set getKnownProperties() {
return knownProperties != null ? knownProperties : DEFAULT_CLIENT_INFO_PROPERTIES;
}
/**
* @return gets the known properties of this instance (guaranteed to be a modifiable set for this instance)
*/
private Set getOrCreateKnownProperties() {
Set knownProperties = this.knownProperties;
if (knownProperties != null) return knownProperties;
return this.knownProperties = new HashSet<>(DEFAULT_CLIENT_INFO_PROPERTIES);
}
/**
* Registers {@code property} as a known property for this instance.
*
* @param property
* client info property to register
*/
// default access for tests
void registerKnownProperty(ClientInfoProperty property) {
if (DEFAULT_CLIENT_INFO_PROPERTIES.contains(property)) return;
getOrCreateKnownProperties().add(property);
}
/**
* Registers {@code properties} as known properties for this instance.
*
* @param properties
* client info properties to register
*/
// default access for tests
void registerKnownProperties(Collection properties) {
if (DEFAULT_CLIENT_INFO_PROPERTIES.containsAll(properties)) return;
getOrCreateKnownProperties().addAll(properties);
}
/**
* Default client info property names for this connection.
*
* @return names of the client info properties
* @see #getDefaultClientInfoProperties()
*/
Collection getDefaultClientInfoPropertyNames() {
return DEFAULT_CLIENT_INFO_PROPERTY_NAMES;
}
/**
* Default client info properties for this connection.
*
* This method only reports the client info properties which are supported by default, any additional properties
* discovered during the lifetime of the connection will not be reported.
*
*
* The current implementation will only report the properties specified in JDBC ({@code ApplicationName},
* {@code ClientUser} and {@code ClientHostname}).
*
*
* @return client info properties
* @see #getDefaultClientInfoPropertyNames()
*/
Collection getDefaultClientInfoProperties() {
return DEFAULT_CLIENT_INFO_PROPERTIES;
}
/**
* Implementation of {@link FBConnection#getClientInfo(String)}.
*/
@SuppressWarnings("SqlSourceToSinkFlow")
public String getClientInfo(String name) throws SQLException {
ClientInfoProperty property;
try {
property = ClientInfoProperty.parse(name);
} catch (RuntimeException e) {
throw new SQLNonTransientException("Invalid client info property name: " + name,
SQLStateConstants.SQL_STATE_INVALID_ATTR_VALUE, e);
}
// Waste of resources to query USER_TRANSACTION in auto-commit mode
if (USER_TRANSACTION.equals(property.context) && connection.getAutoCommit()) return null;
QuoteStrategy quoteStrategy = connection.getQuoteStrategy();
var sb = new StringBuilder("select ");
renderGetValue(sb, property, quoteStrategy);
sb.append(" from RDB$DATABASE");
try (var rs = getStatement().executeQuery(sb.toString())) {
if (rs.next()) {
registerKnownProperty(property);
return rs.getString(1);
}
return null;
} catch (SQLException e) {
if (e.getErrorCode() == ISCConstants.isc_ctx_var_not_found) {
// queried a non-existent SYSTEM variable
return null;
}
throw e;
}
}
private void renderGetValue(StringBuilder sb, ClientInfoProperty property, QuoteStrategy quoteStrategy) {
// CLIENT_PROCESS@SYSTEM was introduced in Firebird 2.5.3, so don't fall back for earlier versions
if (APPLICATION_NAME_PROP.equals(property) && supportInfoFor(connection).isVersionEqualOrAbove(2, 5, 3)) {
sb.append("coalesce(");
property.appendAsGetContext(sb, quoteStrategy).append(',');
APPLICATION_NAME_FALLBACK_PROP.appendAsGetContext(sb, quoteStrategy).append(')');
} else {
property.appendAsGetContext(sb, quoteStrategy);
}
}
/**
* Implementation of {@link FBConnection#getClientInfo()}.
*/
@SuppressWarnings("SqlSourceToSinkFlow")
public Properties getClientInfo() throws SQLException {
boolean autoCommit = connection.getAutoCommit();
QuoteStrategy quoteStrategy = connection.getQuoteStrategy();
var sb = new StringBuilder("""
execute block returns (
CONTEXT_NAME varchar(20) character set ASCII,
CONTEXT_VAR_NAME varchar(80) character set NONE,
CONTEXT_VAR_VALUE varchar(32765) character set NONE)
as
begin
""");
for (ClientInfoProperty property : getKnownProperties()) {
// Skip retrieving USER_TRANSACTION in auto-commit mode
if (autoCommit && USER_TRANSACTION.equals(property.context)) continue;
sb.append("CONTEXT_NAME=");
quoteStrategy.appendLiteral(property.context, sb).append(";\n");
sb.append("CONTEXT_VAR_NAME=");
quoteStrategy.appendLiteral(property.name, sb).append(";\n");
boolean systemContext = SYSTEM.equals(property.context);
if (systemContext) {
// Use block for error handling (ctx_var_not_found is raised only for SYSTEM)
sb.append("""
begin
""");
}
sb.append("CONTEXT_VAR_VALUE=");
renderGetValue(sb, property, quoteStrategy);
sb.append(";\n");
if (systemContext) {
// Error handling for SYSTEM (see above)
sb.append("""
when gdscode ctx_var_not_found do CONTEXT_VAR_VALUE = null;
end
""");
}
sb.append("suspend;\n");
}
sb.append("end");
try (var rs = getStatement().executeQuery(sb.toString())) {
var properties = new Properties();
while (rs.next()) {
var property = new ClientInfoProperty(rs.getString("CONTEXT_VAR_NAME"), rs.getString("CONTEXT_NAME"));
String value = rs.getString("CONTEXT_VAR_VALUE");
if (value != null) {
properties.setProperty(property.toString(), value);
}
}
return properties;
}
}
/**
* Implementation of {@link FBConnection#setClientInfo(String, String)}, but throwing {@code SQLException}
*
* @throws SQLClientInfoException
* for invalid property names or if {@code name} is a property in context {@code SYSTEM} (read-only)
* @throws SQLException
* for database access errors
*/
public void setClientInfo(String name, String value) throws SQLException {
ClientInfoProperty property;
try {
property = ClientInfoProperty.parse(name);
} catch (RuntimeException e) {
throw new SQLClientInfoException("Invalid client info property name: " + name,
SQLStateConstants.SQL_STATE_INVALID_ATTR_VALUE,
Map.of(requireNonNullElse(name, ""), ClientInfoStatus.REASON_UNKNOWN), e);
}
if (SYSTEM.equals(property.context)) {
throw new SQLClientInfoException("Properties in SYSTEM context are read-only: " + name,
SQLStateConstants.SQL_STATE_ATT_CANNOT_SET_NOW, Map.of(name, ClientInfoStatus.REASON_UNKNOWN));
}
// Waste of resources to set USER_TRANSACTION in auto-commit mode
if (USER_TRANSACTION.equals(property.context) && connection.getAutoCommit()) return;
executeSetClientInfo(Map.of(property, value));
}
/**
* Implementation of {@link FBConnection#setClientInfo(Properties)}, but throwing {@code SQLException}.
*
* @throws SQLException
* for database access errors
*/
public void setClientInfo(Properties properties) throws SQLException {
boolean autoCommit = connection.getAutoCommit();
// Include USER_SESSION, and USER_TRANSACTION if not in auto-commit
Predicate includePropertyPredicate = property ->
USER_SESSION.equals(property.context) || !autoCommit && USER_TRANSACTION.equals(property.context);
var propertyValues = new HashMap();
// Populating with null to clear properties not included in parameter properties
getKnownProperties().stream()
.filter(includePropertyPredicate)
.forEach(property -> propertyValues.put(property, null));
Predicate excludePropertyPredicate = includePropertyPredicate.negate();
for (String propertyName : properties.stringPropertyNames()) {
var property = ClientInfoProperty.parse(propertyName);
if (excludePropertyPredicate.test(property)) continue;
propertyValues.put(property, properties.getProperty(propertyName));
}
executeSetClientInfo(propertyValues);
}
@SuppressWarnings("SqlSourceToSinkFlow")
private void executeSetClientInfo(Map propertyValues) throws SQLException {
QuoteStrategy quoteStrategy = connection.getQuoteStrategy();
var sb = new StringBuilder("""
execute block
as
begin
""");
propertyValues.forEach((property, value) ->
property.appendAsSetContext(sb, quoteStrategy, value).append(";\n"));
sb.append("end");
getStatement().execute(sb.toString());
registerKnownProperties(propertyValues.keySet());
}
/**
* Client info property.
*
* @param name
* name of the property (without context)
* @param context
* context
* @since 6
*/
record ClientInfoProperty(String name, String context) {
static final Pattern PROPERTY_PATTERN =
Pattern.compile("^(.*?)@(USER_(?:SESSION|TRANSACTION)|SYSTEM)$", Pattern.DOTALL);
/**
* Creates a client info property.
*
* @param name
* property name
* @param context
* property context
* @throws NullPointerException
* if {@code name} or {@code context} is {@code null}
* @throws IllegalArgumentException
* if {@code name} ends in {@code @USER_SESSION}, {@code @USER_TRANSACTION} or {@code @SYSTEM}, or if
* {@code context} is not {@code USER_SESSION}, {@code USER_TRANSACTION} or {@code SYSTEM}
*/
ClientInfoProperty {
if (PROPERTY_PATTERN.matcher(requireNonNull(name, "name")).matches()) {
throw new IllegalArgumentException("Name '%s' should not end in @ followed by %s"
.formatted(name, SUPPORTED_CONTEXTS));
}
if (!SUPPORTED_CONTEXTS.contains(requireNonNull(context, "context"))) {
throw new IllegalArgumentException(
"Unknown context '%s', expected one of %s".formatted(context, SUPPORTED_CONTEXTS));
}
}
/**
* Parses a property name to a {@link ClientInfoProperty} instance.
*
* If {@code name} ends in {@code @USER_SESSION}, {@code @USER_TRANSACTION} or {@code @SYSTEM}, the name is
* parsed as {@code @}. For all other names, the {@code name} is taken as-is in
* the {@code USER_SESSION} context. As a result of this rules, both {@code SomeProperty} and
* {@code SomeProperty@USER_SESSION} result in the same property.
*
*
* @param name
* property name, cannot be {@code null}, can end in {@code @USER_SESSION}, {@code @USER_TRANSACTION} or
* {@code @SYSTEM} to specify the context, otherwise context {@code USER_SESSION} is used
* @return client info property instance
* @throws NullPointerException
* if name is {@code null}
* @throws IllegalArgumentException
* if name contains multiple context suffixes (e.g. property@SYSTEM@USER_SESSION)
*/
static ClientInfoProperty parse(String name) {
Matcher matcher = PROPERTY_PATTERN.matcher(name);
if (matcher.matches()) {
String propertyName = matcher.group(1);
String context = matcher.group(2);
if (matcher.reset(propertyName).matches()) {
throw new IllegalArgumentException(
"Name '%s' should not end in multiple occurrences of @ followed by %s"
.formatted(name, SUPPORTED_CONTEXTS));
}
return new ClientInfoProperty(propertyName, context);
}
return new ClientInfoProperty(name, USER_SESSION);
}
@Override
public String toString() {
if (USER_SESSION.equals(context)) {
return name;
}
return name + '@' + context;
}
/**
* Renders this property as an {@code RDB$GET_CONTEXT} call into {@code sb} without leading or trailing spaces.
*
* @param sb
* string builder to append to
* @return {@code sb} for chaining calls
*/
StringBuilder appendAsGetContext(StringBuilder sb, QuoteStrategy quoteStrategy) {
// 25 = 16 (prefix) + 6 (4 quotes, comma and closing parenthesis) + space for three quotes to escape
sb.ensureCapacity(sb.length() + 25 + context.length() + name.length());
sb.append("RDB$GET_CONTEXT(");
quoteStrategy.appendLiteral(context, sb);
sb.append(',');
quoteStrategy.appendLiteral(name, sb);
return sb.append(')');
}
/**
* Renders this property as an {@code RDB$SET_CONTEXT} call with {@code value} into {@code sb} without leading
* or trailing spaces.
*
* @param sb
* string builder to append to
* @param value
* value to set, use {@code null} to set SQL {@code NULL}
* @return {@code sb} for chaining calls
*/
StringBuilder appendAsSetContext(StringBuilder sb, QuoteStrategy quoteStrategy, String value) {
// 30 = 16 (prefix) + 9 (6 quotes, 2 commas, and closing parenthesis) + space for five quotes to escape
sb.ensureCapacity(
sb.length() + 30 + context.length() + name.length() + (value != null ? value.length() : 4));
sb.append("RDB$SET_CONTEXT(");
quoteStrategy.appendLiteral(context, sb);
sb.append(',');
quoteStrategy.appendLiteral(name, sb);
sb.append(',');
if (value == null) {
sb.append("NULL");
} else {
quoteStrategy.appendLiteral(value, sb);
}
return sb.append(')');
}
}
}