package fr.pandacube.lib.db;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import fr.pandacube.lib.reflect.Reflect;
import fr.pandacube.lib.util.Log;
/**
* Static class to handle most of the database operations.
*
* To use this database library, first call {@link #init(DBConnection, String)} with an appropriate {@link DBConnection},
* then you can initialize every table you need for your application, using {@link #initTable(Class)}.
*
* @author Marc Baloup
*/
public final class DB {
private static final List>> tables = new ArrayList<>();
private static final Map>, String> tableNames = new HashMap<>();
private static DBConnection connection;
/* package */ static String tablePrefix = "";
/**
* Gets the {@link DBConnection}.
* @return the {@link DBConnection}.
*/
public static DBConnection getConnection() {
return connection;
}
/**
* Initialize with the provided connection.
* @param conn the database connection.
* @param tablePrefix determine a prefix for the table that will be initialized.
*/
public synchronized static void init(DBConnection conn, String tablePrefix) {
connection = conn;
DB.tablePrefix = Objects.requireNonNull(tablePrefix);
}
/**
* Initialize the table represented by the provided class.
* @param elemClass the class representing a table.
* @param the type representing the table.
* @throws DBInitTableException if the table failed to initialize.
*/
public static synchronized > void initTable(Class elemClass) throws DBInitTableException {
if (connection == null) {
throw new DBInitTableException(elemClass, "Database connection is not yet initialized.");
}
if (tables.contains(elemClass)) return;
try {
tables.add(elemClass);
Log.debug("[DB] Start Init SQL table "+elemClass.getSimpleName());
E instance = elemClass.getConstructor().newInstance();
String tableName = tablePrefix + instance.tableName();
tableNames.put(elemClass, tableName);
if (!tableExistInDB(tableName)) createTable(instance);
Log.debug("[DB] End init SQL table "+elemClass.getSimpleName());
} catch (Exception|ExceptionInInitializerError e) {
throw new DBInitTableException(elemClass, e);
}
}
private static > void createTable(E elem) throws SQLException {
String tableName = tablePrefix + elem.tableName();
StringBuilder sql = new StringBuilder("CREATE TABLE IF NOT EXISTS " + tableName + " (");
List params = new ArrayList<>();
Collection> tableFields = elem.getFields().values();
boolean first = true;
for (SQLField f : tableFields) {
ParameterizedSQLString statementPart = f.forSQLPreparedStatement();
params.addAll(statementPart.parameters());
if (!first)
sql.append(", ");
first = false;
sql.append(statementPart.sqlString());
}
sql.append(", PRIMARY KEY id(id))");
try (Connection c = connection.getConnection();
PreparedStatement ps = c.prepareStatement(sql.toString())) {
int i = 1;
for (Object val : params)
ps.setObject(i++, val);
Log.info("Creating table " + elem.tableName() + ":\n" + ps.toString());
ps.executeUpdate();
}
}
/**
* Gets the name of the table in the database.
* @param elemClass the class representing a table.
* @return a table name.
* @param the type representing the table.
* @throws DBInitTableException if the provided table had to be initialized and failed to do so.
*/
public static > String getTableName(Class elemClass) throws DBInitTableException {
initTable(elemClass);
return tableNames.get(elemClass);
}
private static boolean tableExistInDB(String tableName) throws SQLException {
try (Connection c = connection.getConnection();
ResultSet set = c.getMetaData().getTables(null, null, tableName, null)) {
return set.next();
}
}
/**
* Gets the {@code id} field of the provided table.
* @param elemClass the class representing a table.
* @return the {@code id} field of the provided table.
* @param the type representing the table.
* @throws DBInitTableException if the provided table had to be initialized and failed to do so.
*/
@SuppressWarnings("unchecked")
public static > SQLField getSQLIdField(Class elemClass) throws DBInitTableException {
initTable(elemClass);
return (SQLField) SQLElement.fieldsCache.get(elemClass).get("id");
}
/**
* Fetch the entry from the provided table, that has the specified ids.
* @param elemClass the class representing a table.
* @param ids the ids of the element entries.
* @return the entry from the provided table, that has the specified ids.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > SQLElementList getByIds(Class elemClass, Integer... ids) throws DBException {
return getByIds(elemClass, Arrays.asList(ids));
}
/**
* Fetch the entry from the provided table, that has the specified ids.
* @param elemClass the class representing a table.
* @param ids the ids of the element entries.
* @return the entry from the provided table, that has the specified ids.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > SQLElementList getByIds(Class elemClass, Collection ids) throws DBException {
return getAll(elemClass, getSQLIdField(elemClass).in(ids), SQLOrderBy.asc(getSQLIdField(elemClass)), 1, null);
}
/**
* Fetch the entry from the provided table, that has the specified id.
* @param elemClass the class representing a table.
* @param id the id of the element entry.
* @return the entry from the provided table, that has the specified id, or null if none was found.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > E getById(Class elemClass, int id) throws DBException {
return getFirst(elemClass, getSQLIdField(elemClass).eq(id));
}
/**
* Fetch the entry from the provided table, using the provided {@code WHERE} clause,
* and a {@code LIMIT} of 1.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @return the entry from the provided table, or null if none was found.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > E getFirst(Class elemClass, SQLWhere where) throws DBException {
return getFirst(elemClass, where, null, null);
}
/**
* Fetch the entry from the provided table, using the provided {@code ORDER BY} clause,
* and a {@code LIMIT} of 1.
* @param elemClass the class representing a table.
* @param orderBy the {@code ORDER BY} clause of the query.
* @return the entry from the provided table, or null if none was found.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > E getFirst(Class elemClass, SQLOrderBy orderBy) throws DBException {
return getFirst(elemClass, null, orderBy, null);
}
/**
* Fetch the entry from the provided table, using the provided {@code WHERE} and {@code ORDER BY} clauses,
* and a {@code LIMIT} of 1.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param orderBy the {@code ORDER BY} clause of the query.
* @return the entry from the provided table, or null if none was found.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > E getFirst(Class elemClass, SQLWhere where, SQLOrderBy orderBy) throws DBException {
return getFirst(elemClass, where, orderBy, null);
}
/**
* Fetch the entry from the provided table, using the provided {@code WHERE}, {@code ORDER BY} and {@code OFFSET}
* clauses, and a {@code LIMIT} of 1.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param orderBy the {@code ORDER BY} clause of the query.
* @param offset the {@code OFFSET} clause of the query.
* @return the entry from the provided table, or null if none was found.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > E getFirst(Class elemClass, SQLWhere where, SQLOrderBy orderBy, Integer offset) throws DBException {
SQLElementList elements = getAll(elemClass, where, orderBy, 1, offset);
return (elements.size() == 0) ? null : elements.get(0);
}
/**
* Fetch all the entries from the provided table.
* @param elemClass the class representing a table.
* @return the entries from the provided table, or empty if none was found.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > SQLElementList getAll(Class elemClass) throws DBException {
return getAll(elemClass, null, null, null, null);
}
/**
* Fetch the entries from the provided table, using the provided {@code WHERE} clause.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @return the entries from the provided table, or empty if none was found.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > SQLElementList getAll(Class elemClass, SQLWhere where) throws DBException {
return getAll(elemClass, where, null, null, null);
}
/**
* Fetch the entries from the provided table, using the provided {@code WHERE} and {@code ORDER BY} clauses.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param orderBy the {@code ORDER BY} clause of the query.
* @return the entries from the provided table, or empty if none was found.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > SQLElementList getAll(Class elemClass, SQLWhere where, SQLOrderBy orderBy) throws DBException {
return getAll(elemClass, where, orderBy, null, null);
}
/**
* Fetch the entries from the provided table, using the provided {@code WHERE}, {@code ORDER BY} and {@code LIMIT}
* clauses.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param orderBy the {@code ORDER BY} clause of the query.
* @param limit the {@code LIMIT} clause of the query.
* @return the entries from the provided table, or empty if none was found.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > SQLElementList getAll(Class elemClass, SQLWhere where, SQLOrderBy orderBy, Integer limit) throws DBException {
return getAll(elemClass, where, orderBy, limit, null);
}
/**
* Fetch the entries from the provided table, using the provided {@code WHERE}, {@code ORDER BY}, {@code LIMIT} and
* {@code OFFSET} clauses.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param orderBy the {@code ORDER BY} clause of the query.
* @param limit the {@code LIMIT} clause of the query.
* @param offset the {@code OFFSET} clause of the query.
* @return the entries from the provided table, or empty if none was found.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > SQLElementList getAll(Class elemClass, SQLWhere where, SQLOrderBy orderBy, Integer limit, Integer offset) throws DBException {
SQLElementList elements = new SQLElementList<>();
forEach(elemClass, where, orderBy, limit, offset, elements::add);
return elements;
}
/**
* Iterate through all the entries from the provided table.
* @param elemClass the class representing a table.
* @param action the action to perform on each entry.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > void forEach(Class elemClass, Consumer action) throws DBException {
forEach(elemClass, null, null, null, null, action);
}
/**
* Iterate through the entries from the provided table, using the provided {@code WHERE} clause.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param action the action to perform on each entry.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > void forEach(Class elemClass, SQLWhere where, Consumer action) throws DBException {
forEach(elemClass, where, null, null, null, action);
}
/**
* Iterate through the entries from the provided table, using the provided {@code WHERE} and {@code ORDER BY}
* clauses.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param orderBy the {@code ORDER BY} clause of the query.
* @param action the action to perform on each entry.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > void forEach(Class elemClass, SQLWhere where, SQLOrderBy orderBy, Consumer action) throws DBException {
forEach(elemClass, where, orderBy, null, null, action);
}
/**
* Iterate through the entries from the provided table, using the provided {@code WHERE}, {@code ORDER BY} and
* {@code LIMIT} clauses.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param orderBy the {@code ORDER BY} clause of the query.
* @param limit the {@code LIMIT} clause of the query.
* @param action the action to perform on each entry.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > void forEach(Class elemClass, SQLWhere where, SQLOrderBy orderBy, Integer limit, Consumer action) throws DBException {
forEach(elemClass, where, orderBy, limit, null, action);
}
/**
* Iterate through the entries from the provided table, using the provided {@code WHERE}, {@code ORDER BY},
* {@code LIMIT} and {@code OFFSET} clauses.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param orderBy the {@code ORDER BY} clause of the query.
* @param limit the {@code LIMIT} clause of the query.
* @param offset the {@code OFFSET} clause of the query.
* @param action the action to perform on each entry.
* @param the type representing the table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > void forEach(Class elemClass, SQLWhere where, SQLOrderBy orderBy, Integer limit, Integer offset, Consumer action) throws DBException {
initTable(elemClass);
String sql = "SELECT * FROM " + getTableName(elemClass);
List params = new ArrayList<>();
if (where != null) {
ParameterizedSQLString ret = where.toSQL();
sql += " WHERE " + ret.sqlString();
params.addAll(ret.parameters());
}
if (orderBy != null)
sql += " ORDER BY " + orderBy.toSQL();
if (limit != null)
sql += " LIMIT " + limit;
if (offset != null)
sql += " OFFSET " + offset;
sql += ";";
customQueryStatement(sql, params, set -> {
while (set.next()) {
E elm = getElementInstance(set, elemClass);
action.accept(elm);
}
return null;
});
}
/**
* Counts the number of entries in the provided table.
* @param elemClass the class representing a table.
* @param the type representing the table.
* @return the number of entries in the provided table.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > long count(Class elemClass) throws DBException {
return count(elemClass, null);
}
/**
* Counts the number of entries from the provided table, that meet the {@code WHERE} clause conditions.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param the type representing the table.
* @return the number of entries from the provided table, that meet the {@code WHERE} clause conditions.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > long count(Class elemClass, SQLWhere where) throws DBException {
initTable(elemClass);
String sql = "SELECT COUNT(*) AS count FROM " + getTableName(elemClass);
List params = new ArrayList<>();
if (where != null) {
ParameterizedSQLString ret = where.toSQL();
sql += " WHERE " + ret.sqlString();
params.addAll(ret.parameters());
}
sql += ";";
return customQueryStatement(sql, params, rs -> {
if (rs.next()) {
return rs.getLong(1);
}
throw new DBException("Can’t retrieve element count from database (the ResultSet is empty).");
});
}
/**
* Execute a custom SQL query statement with the provided parameters, and passes the produced {@link ResultSet}
* to the {@code rsFunction}.
* @param sql the query in SQL language, passed to {@link Connection#prepareStatement(String)}.
* @param params the parameters to put in the query. Uses {@link PreparedStatement#setObject(int, Object)}.
* @param rsFunction the function executed with the result set as the parameter. Its return value will then be
* returned to the caller of this method.
* @param the return type of {@code rsFunction}.
* @return the value returned by {@code rsFunction}.
* @throws DBException if an error occurs when interacting with the database.
*/
public static R customQueryStatement(String sql, List params, ResultSetFunction rsFunction) throws DBException {
try (Connection c = connection.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
for (Object val : params) {
if (val instanceof Enum>) val = ((Enum>) val).name();
ps.setObject(i++, val);
}
Log.debug(ps.toString());
try (ResultSet set = ps.executeQuery()) {
return rsFunction.apply(set);
}
} catch (SQLException e) {
throw new DBException(e);
}
}
/**
* A function that takes a {@link ResultSet} as an input and output any value.
* @param the return type.
*/
@FunctionalInterface
public interface ResultSetFunction {
/**
* Reads data into the result set.
* @param resultSet the result set to read.
* @throws SQLException if an error occurs while reading the result set.
* @throws DBException if an error occurs while reading the result set.
* @return data computed from the resultSet.
*/
R apply(ResultSet resultSet) throws SQLException, DBException;
}
/**
* Prepares an UPDATE query to the database.
* Call the {@link SQLUpdateBuilder#set(SQLField, Object)} for any field you want to change the value, then call
* {@link SQLUpdateBuilder#execute()} to send the query.
* @param elemClass the class representing a table.
* @param where the {@code WHERE} clause of the query.
* @param the type representing the table.
* @return an {@link SQLUpdateBuilder} instance.
*/
public static > SQLUpdateBuilder update(Class elemClass, SQLWhere where) {
return new SQLUpdateBuilder<>(elemClass, where);
}
/* package */ static > int update(Class elemClass, SQLWhere where, Map, Object> values) throws DBException {
return new SQLUpdateBuilder<>(elemClass, where, values).execute();
}
/**
* Delete the entries from the provided table, using the provided {@code WHERE} clause.
* @param elemClass the class representing a table.
* @param where the condition to meet for an element to be deleted from the table. If null, the table is truncated
* using {@link #truncateTable(Class)}.
* @param the type representing the table.
* @return The return value of {@link PreparedStatement#executeUpdate()}, for an SQL query {@code DELETE}.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > int delete(Class elemClass, SQLWhere where) throws DBException {
initTable(elemClass);
if (where == null) {
return truncateTable(elemClass);
}
ParameterizedSQLString whereData = where.toSQL();
String sql = "DELETE FROM " + getTableName(elemClass)
+ " WHERE " + whereData.sqlString()
+ ";";
List params = new ArrayList<>(whereData.parameters());
return customUpdateStatement(sql, params);
}
/**
* Execute a custom SQL update statement with the provided parameters.
* @param sql the query in SQL language, passed to {@link Connection#prepareStatement(String)}.
* @param params the parameters to put in the query. Uses {@link PreparedStatement#setObject(int, Object)}.
* @return the value returned by {@link PreparedStatement#executeUpdate()}.
* @throws DBException if an error occurs when interacting with the database.
*/
public static int customUpdateStatement(String sql, List params) throws DBException {
try (Connection c = connection.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
for (Object val : params) {
if (val instanceof Enum>) val = ((Enum>) val).name();
ps.setObject(i++, val);
}
Log.debug(ps.toString());
return ps.executeUpdate();
} catch (SQLException e) {
throw new DBException(e);
}
}
/**
* Truncate provided table.
* @param elemClass the class representing a table.
* @param the type representing the table.
* @return The return value of {@link PreparedStatement#executeUpdate()}, for an SQL query {@code DELETE}.
* @throws DBException if an error occurs when interacting with the database.
*/
public static > int truncateTable(Class elemClass) throws DBException {
try (Connection c = connection.getConnection();
Statement stmt = c.createStatement()) {
return stmt.executeUpdate("TRUNCATE `" + getTableName(elemClass) + "`");
} catch(SQLException e) {
throw new DBException(e);
}
}
@SuppressWarnings("unchecked")
private static > E getElementInstance(ResultSet set, Class elemClass) throws DBException {
try {
E instance = Reflect.ofClass(elemClass).constructor(int.class).instantiate(set.getInt("id"));
int fieldCount = set.getMetaData().getColumnCount();
for (int c = 1; c <= fieldCount; c++) {
String fieldName = set.getMetaData().getColumnLabel(c);
// ignore when field is present in database but not handled by SQLElement instance
if (!instance.getFields().containsKey(fieldName)) continue;
SQLField sqlField = (SQLField) instance.getFields().get(fieldName);
boolean customType = sqlField.type instanceof SQLCustomType;
Object val = set.getObject(c,
(Class>)(customType ? ((SQLCustomType, ?>)sqlField.type).intermediateJavaType
: sqlField.type.getJavaType()));
if (val == null || set.wasNull()) {
instance.set(sqlField, null, false);
}
else {
if (customType) {
try {
val = ((SQLCustomType)sqlField.type).dbToJavaConv.apply(val);
} catch (Exception e) {
throw new DBException("Error while converting value of field '"+sqlField.getName()+"' with SQLCustomType from "+((SQLCustomType)sqlField.type).intermediateJavaType
+"(jdbc source) to "+sqlField.type.getJavaType()+"(java destination). The original value is '"+ val +"'", e);
}
}
/*
* The value from the DB is marked as not-modified in the entry instance since this boolean is set
* only when the value differs from the DB.
*/
instance.set(sqlField, val, false);
instance.modifiedSinceLastSave.remove(sqlField.getName());
}
}
if (!instance.isValidForSave()) throw new DBException(
"This SQLElement representing a database entry is not valid for save : " + instance);
return instance;
} catch (ReflectiveOperationException | IllegalArgumentException | SecurityException | SQLException e) {
throw new DBException("Can't instantiate " + elemClass.getName(), e);
}
}
private DB() {}
}