org.tentackle.dbms.DbUtilities Maven / Gradle / Ivy
/*
* Tentackle - https://tentackle.org
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library 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 GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.tentackle.dbms;
import org.tentackle.common.Constants;
import org.tentackle.common.EncryptedProperties;
import org.tentackle.common.Service;
import org.tentackle.common.ServiceFactory;
import org.tentackle.dbms.rmi.RemoteDbSessionImpl;
import org.tentackle.io.ReconnectionPolicy;
import org.tentackle.session.BackendConfiguration;
import org.tentackle.session.PersistenceException;
import org.tentackle.session.Session;
import org.tentackle.session.SessionPoolProvider;
import org.tentackle.sql.Backend;
import org.tentackle.sql.DataType;
import org.tentackle.sql.DataTypeFactory;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.function.Consumer;
import java.util.function.Supplier;
interface DbUtilitiesHolder {
DbUtilities INSTANCE = ServiceFactory.createService(DbUtilities.class, DbUtilities.class);
}
/**
* Persistence utility methods.
* This singleton is provided mainly to allow a clean separation between the
* lower- and higher level persistence layer implementations.
* It is replaced by {@code PersistenceUtilities} from the tentackle-persistence module
* to make it PDO-aware.
*
* @author harald
*/
@Service(DbUtilities.class) // defaults to self
public class DbUtilities {
/**
* The singleton.
*
* @return the singleton
*/
public static DbUtilities getInstance() {
return DbUtilitiesHolder.INSTANCE;
}
private ConnectionManager connectionManager;
/**
* Creates the db utitities.
*/
public DbUtilities() {
// see -Xlint:missing-explicit-ctor since Java 16
}
/**
* Creates a session-less object for given class.
*
* @param the object type
* @param clazz the object class
* @return the initialized object, null if clazz is not a persistence class
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
public T createObject(Class clazz) {
T object = null;
if (AbstractDbObject.class.isAssignableFrom(clazz)) {
object = (T) AbstractDbObject.newInstance((Class) clazz);
}
return object;
}
/**
* Loads an object from the database.
*
* @param the object type
* @param clazz the object class
* @param session the session
* @param objectId the object id
* @param loadLazyReferences true if load lazy references
* @return the object, null if no such object
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
public T selectObject(Session session, Class clazz, long objectId, boolean loadLazyReferences) {
T object = null;
if (AbstractDbObject.class.isAssignableFrom(clazz)) {
object = (T) AbstractDbObject.newInstance(session, (Class) clazz).selectObject(objectId);
// load any lazy references that may be necessary for replay on the remote side
if (object != null && loadLazyReferences) {
((AbstractDbObject) object).loadLazyReferences();
}
}
return object;
}
/**
* Determines whether table serial is valid for this pdo class.
*
* @param clazzVar the class variables
* @return the tablename holding the tableserial, null if no tableserial
*/
public String determineTableSerialTableName(DbObjectClassVariables> clazzVar) {
try {
String tableSerialTableName = null;
AbstractDbObject> po = AbstractDbObject.newInstance(clazzVar.clazz);
if (po.isTableSerialProvided()) {
tableSerialTableName = po.getTableName();
}
return tableSerialTableName;
}
catch (RuntimeException ex) {
throw new IllegalStateException(
"can't evaluate the name of the table holding the tableserial for " + clazzVar, ex);
}
}
/**
* Gets the default connection manager.
*
* @return the connection manager, never null
*/
public synchronized ConnectionManager getDefaultConnectionManager() {
if (connectionManager == null) {
connectionManager = new DefaultConnectionManager();
}
return connectionManager;
}
/**
* Gets the session pool provider.
*
* @return the session pool provider, null if none (default)
*/
public SessionPoolProvider getSessionPoolProvider() {
return null;
}
/**
* Determines the serviced class.
*
* @param implementingClass the implementing class
* @return the serviced class, null if none
*/
public Class> getServicedClass(Class> implementingClass) {
// overridden in PersistenceUtilities
return null;
}
/**
* Creates a reconnection policy for a given session.
*
* @param session the session
* @param blocking true if reconnection blocks the current thread, else non-blocking in background
* @param millis the (minimum) time in milliseconds between retries
* @return the policy
*/
public ReconnectionPolicy createReconnectionPolicy(Db session, boolean blocking, long millis) {
return new ReconnectionPolicy<>() {
@Override
public String toString() {
return session.toString();
}
@Override
public boolean isBlocking() {
return blocking;
}
@Override
public long timeToReconnect() {
return millis;
}
@Override
public Supplier getConnector() {
return () -> session;
}
@Override
public Consumer getConsumer() {
return Db::reOpen;
}
};
}
/**
* Performs any clean up when a remote user session is closed.
*
* @param remoteSession the remote session
*/
public void cleanupRemoteSession(RemoteDbSessionImpl remoteSession) {
// the default does nothing
}
/**
* Adds a session to a session group.
* Creates a new group if the session with the given groupId does not belong to a group yet.
*
* @param session the session to add to a group
* @param sessionGroupId the session ID or session group ID to group with
* @param fromRemote true if initiated from remote client
*/
public void addToSessionGroup(Db session, int sessionGroupId, boolean fromRemote) {
if (sessionGroupId <= 0) {
throw new PersistenceException(session, "invalid requested session group " + sessionGroupId);
}
String url = session.getUrl();
int oldGroup = fromRemote ? session.getExportedSessionGroupId() : session.getSessionGroupId();
if (oldGroup != 0) {
if (sessionGroupId != oldGroup) {
throw new PersistenceException(session, "session already belongs to group " + oldGroup +
" (requested was " + sessionGroupId);
}
return; // nothing to do
}
Db session2 = Db.getOpenSession(sessionGroupId, url);
if (session2 == null) {
throw new PersistenceException(session, "no such session with ID " + sessionGroupId + " to group with");
}
int groupId = fromRemote ? session2.getExportedSessionGroupId() : session2.getSessionGroupId();
if (groupId != 0) {
if (groupId != sessionGroupId) {
throw new PersistenceException(session2, "session already belongs to exported group " + groupId);
}
}
else {
// not grouped so far: create it
groupId = sessionGroupId;
}
if (fromRemote) {
session2.setExportedSessionGroupId(groupId);
session.setExportedSessionGroupId(groupId);
}
else {
session2.setSessionGroupId(groupId);
session.setSessionGroupId(groupId);
}
}
/**
* Close session groups if session is a root session of a group.
*
* @param session the closing session
*/
public void closeGroupsOfSession(Db session) {
int sessionId = session.getSessionId();
boolean isGroupRoot = sessionId == session.getSessionGroupId();
boolean isExportedGroupRoot = sessionId == session.getExportedSessionGroupId();
if (isGroupRoot || isExportedGroupRoot) {
for (Db db: Db.getAllOpenSessions()) {
if (isGroupRoot && db.getSessionGroupId() == sessionId) {
db.setSessionGroupId(0);
}
if (isExportedGroupRoot && db.getExportedSessionGroupId() == sessionId) {
db.setExportedSessionGroupId(0);
}
}
}
}
/**
* Applies the given backend configuration to properties.
*
* @param backendConfiguration the backend configuration
* @param properties the session properties.
*/
public void applyBackendConfiguration(BackendConfiguration backendConfiguration, EncryptedProperties properties) {
if (backendConfiguration.getUser() != null) {
properties.setProperty(Constants.BACKEND_USER, backendConfiguration.getUser());
}
if (backendConfiguration.getPassword() != null) {
properties.setEncryptedProperty(Constants.BACKEND_PASSWORD, backendConfiguration.getPassword());
}
properties.setProperty(Constants.BACKEND_URL, backendConfiguration.getUrl());
if (backendConfiguration.getDriver() != null) {
// ex.: "org.postgresql.Driver:jar:file:/usr/share/java/postgresql.jar!/"
properties.setProperty(Constants.BACKEND_DRIVER,
backendConfiguration.getDriver().getDriver() + ":jar:file:" + backendConfiguration.getDriver().getUrl() + "!/");
}
if (backendConfiguration.getOptions() != null) {
for (String line: backendConfiguration.getOptions().split("\n")) {
String key;
String value;
int ndx = line.indexOf('=');
if (ndx >= 0) {
key = line.substring(0, ndx).trim();
value = line.substring(ndx + 1).trim();
}
else {
key = line.trim();
value = "";
}
properties.setProperty(key, value);
}
}
}
/**
* Notifies interested parties that a physical rollback has happened.
*
* @param session the session
* @param txNumber the transaction number
*/
public void notifyRollback(Db session, long txNumber) {
// nothing to do
}
/**
* Notifies interested parties that a physical commit has happened.
*
* @param session the session
* @param txNumber the transaction number
*/
public void notifyCommit(Db session, long txNumber) {
// nothing to do
}
/**
* Holds the mapping between a parameter type and its positions in the resultset.
*/
private record ColumnMapping(DataType> dataType, int[] positions) {
private Object getParameter(ResultSetWrapper rs) {
return rs.get(dataType, positions, false, 0);
}
}
/**
* Holds the mapping of a datatype to a setter or builder method.
*/
private record MethodMapping(Method method, ColumnMapping columnMapping) {
private void invoke(Object object, ResultSetWrapper rs) throws InvocationTargetException, IllegalAccessException {
method.invoke(object, columnMapping.getParameter(rs));
}
}
/**
* Converts a resultset to a list of DTOs.
* Works for builder- and setter pattern and all registered {@link DataType}s.
* Constructor pattern works for records, but not for classes, since in Java the parameters are unnamed.
*
* For the builder pattern see the DTO wurblet.
* For the setter pattern the DTO class needs a public no-arg constructor.
* For the record pattern the canonical constructor is used.
*
* The method- and columnnames are matched case-insensitive with all underscores removed.
* For primitive members of the DTO class the result set must not contain null values, of course.
*
* @param rs the resultset
* @param clazz the DTO class
* @param the DTO type
* @return the list of objects
* @see DataType
*/
@SuppressWarnings("unchecked")
public List resultSetToList(ResultSetWrapper rs, Class clazz) {
if (rs.isInSkipMode()) {
throw new PersistenceException("result set must not be in skipmode");
}
if (rs.getColumnOffset() != 0) {
throw new PersistenceException("column offset must be zero");
}
String[] columnNames; // converted column names
Method builderMethod; // static method to create a builder, if builder pattern is used
Class> builderClass; // the class of the builder
int columnCount = rs.getColumnCount(); // number of result columns
List dtos = new ArrayList<>(); // returned list of DTOs
Method buildMethod = null; // the build-method of the builder
Constructor noArgConstructor = null; // != null for the setter pattern
Constructor recordConstructor = null; // != null for the record pattern
List builderMappings = new ArrayList<>(); // mappings for the builder pattern
List setterMappings = new ArrayList<>(); // mappings for the setters
List recordMappings = null; // != null if mappings for the record pattern were found
Backend backend = rs.getSession().getBackend(); // the backend
// build converted column names
columnNames = new String[columnCount];
for (int i=0; i < columnCount; i++) {
columnNames[i] = normalizeColumnName(rs.getColumnName(i + 1));
}
// find all setters
if (clazz.isRecord()) {
RecordComponent[] recordComponents = clazz.getRecordComponents();
// find the record constructor
for (Constructor> constructor : clazz.getDeclaredConstructors()) {
List mappings = new ArrayList<>();
for (RecordComponent recordComponent : recordComponents) {
ColumnMapping mapping = createColumnMapping(backend, recordComponent.getName(), columnNames, recordComponent.getType());
if (mapping != null) {
mappings.add(mapping);
}
}
if (mappings.size() == recordComponents.length) {
// canonical constructor found!
recordConstructor = (Constructor) constructor;
recordMappings = mappings;
break;
}
}
}
else {
for (Method method : clazz.getMethods()) {
if (method.getDeclaringClass() != Object.class &&
method.getReturnType() == Void.TYPE && method.getParameterCount() == 1 &&
method.getName().startsWith("set")) {
String name = method.getName().substring(3);
if (!name.isEmpty() && Character.isUpperCase(name.charAt(0))) {
MethodMapping mapping = createMethodMapping(backend, name, columnNames, method);
if (mapping != null) {
setterMappings.add(mapping);
}
}
}
}
}
// check for builder pattern (see DTO.wrbl)
// This is even possible for records!
try {
builderMethod = clazz.getDeclaredMethod("builder");
if (!Modifier.isStatic(builderMethod.getModifiers()) || !Modifier.isPublic(builderMethod.getModifiers())) {
builderMethod = null;
}
else {
builderClass = builderMethod.invoke(null).getClass();
buildMethod = builderClass.getDeclaredMethod("build");
if (buildMethod.getReturnType() != clazz) {
builderMethod = null;
}
else {
for (Method method : builderClass.getMethods()) {
if (method.getDeclaringClass() != Object.class &&
method.getReturnType() == Void.TYPE && method.getParameterCount() == 1) {
MethodMapping mapping = createMethodMapping(backend, method.getName(), columnNames, method);
if (mapping != null) {
builderMappings.add(mapping);
}
}
}
}
}
// if builder is still null, could be setter pattern
}
catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | NullPointerException ex) {
// that's ok...
builderMethod = null;
}
if (builderMethod == null && !clazz.isRecord()) {
try {
noArgConstructor = clazz.getConstructor();
}
catch (NoSuchMethodException ex) {
throw new PersistenceException("missing no-arg constructor in " + clazz);
}
}
if (builderMappings.isEmpty() && setterMappings.isEmpty() && recordMappings == null) {
StringBuilder buf = new StringBuilder();
buf.append("cannot create ").append(clazz.getSimpleName()).append(" from ");
boolean needComma = false;
for (String columnName : columnNames) {
if (needComma) {
buf.append(", ");
}
else {
needComma = true;
}
buf.append(columnName);
}
throw new PersistenceException(buf.toString());
}
try {
while (rs.next()) {
Object builder;
T dto;
if (builderMethod != null) {
builder = builderMethod.invoke(null);
retrieveMethodMappings(builderMappings, builder, rs);
dto = (T) buildMethod.invoke(builder);
}
else if (recordConstructor != null) {
dto = retrieveRecordMappings(recordConstructor, recordMappings, rs);
}
else {
dto = noArgConstructor.newInstance();
retrieveMethodMappings(setterMappings, dto, rs);
}
dtos.add(dto);
}
}
catch (IllegalAccessException | InvocationTargetException | InstantiationException ex) {
throw new PersistenceException("creating DTO failed", ex);
}
return dtos;
}
private String normalizeColumnName(String name) {
return name.replace("_", "").toLowerCase(Locale.ROOT); // remove underscores and convert to lowercase
}
private int[] determinePositions(Backend backend, String memberName, String[] columnNames, DataType> dataType) {
int[] positions = new int[dataType.getColumnCount(backend)];
for (int p=0; p < positions.length; p++) {
String normalizedColumnName = normalizeColumnName(memberName + dataType.getColumnSuffix(backend, p).orElse(""));
boolean found = false;
for (int c=0; c < columnNames.length; c++) {
if (normalizedColumnName.equals(columnNames[c])) {
positions[p] = c + 1;
found = true;
break;
}
}
if (!found) {
positions = null;
break;
}
}
return positions;
}
private ColumnMapping createColumnMapping(Backend backend, String memberName, String[] columnNames, Class> type) {
ColumnMapping columnMapping = null;
DataType> dataType = DataTypeFactory.getInstance().get(type);
if (dataType != null) {
int[] positions = determinePositions(backend, memberName, columnNames, dataType);
if (positions != null) {
// all columns found in resultset!
columnMapping = new ColumnMapping(dataType, positions);
}
}
return columnMapping;
}
private MethodMapping createMethodMapping(Backend backend, String memberName, String[] columnNames, Method method) {
MethodMapping methodMapping = null;
ColumnMapping columnMapping = createColumnMapping(backend, memberName, columnNames, method.getParameterTypes()[0]);
if (columnMapping != null) {
methodMapping = new MethodMapping(method, columnMapping);
}
return methodMapping;
}
private void retrieveMethodMappings(List methodMappings, Object object, ResultSetWrapper rs)
throws InvocationTargetException, IllegalAccessException {
for (MethodMapping methodMapping : methodMappings) {
methodMapping.invoke(object, rs);
}
}
private T retrieveRecordMappings(Constructor recordConstructor, List recordMappings, ResultSetWrapper rs)
throws InvocationTargetException, InstantiationException, IllegalAccessException {
Object[] args = new Object[recordMappings.size()];
int argNdx = 0;
for (ColumnMapping recordMapping : recordMappings) {
args[argNdx++] = recordMapping.getParameter(rs);
}
return recordConstructor.newInstance(args);
}
}