446 lines
15 KiB
Java
446 lines
15 KiB
Java
package de.srsoftware.widerhall.data;
|
|
|
|
import de.srsoftware.widerhall.Configuration;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import java.sql.Connection;
|
|
import java.sql.DriverManager;
|
|
import java.sql.ResultSet;
|
|
import java.sql.SQLException;
|
|
import java.util.*;
|
|
|
|
import static de.srsoftware.widerhall.Constants.*;
|
|
import static de.srsoftware.widerhall.Util.t;
|
|
import static de.srsoftware.widerhall.data.MailingList.HOLD_TIME;
|
|
|
|
/**
|
|
* @author Stephan Richter, 2022
|
|
* This class abstracts away all needed database operations
|
|
*/
|
|
public class Database {
|
|
private static final Logger LOG = LoggerFactory.getLogger(Database.class);
|
|
private static final String DB_VERSION = "db_version";
|
|
private static Database singleton = null; // we only need one db handle ever. This will be it.
|
|
private final Connection conn; // the actual db connection handle within the singleton
|
|
|
|
/**
|
|
* This class encapsulates a compiled request.
|
|
* A compiled request is a ready-to execute sql string paired with a list of parameters
|
|
*/
|
|
public class CompiledRequest{
|
|
private final String sql;
|
|
private final List<Object> args;
|
|
|
|
/**
|
|
* create new instance
|
|
* @param sql final sql, ready to be executed
|
|
* @param args arguments for the execution of the query
|
|
*/
|
|
public CompiledRequest(String sql, List<Object> args){
|
|
this.sql = sql;
|
|
this.args = args;
|
|
}
|
|
|
|
/**
|
|
* build the final SQL and execute the actual request (for SELECT statements)
|
|
* @return the result set produced by the SELECT statement
|
|
* @throws SQLException
|
|
*/
|
|
public ResultSet exec() throws SQLException {
|
|
LOG.debug("Executing {}",this);
|
|
try {
|
|
var stmt = conn.prepareStatement(sql);
|
|
for (int i = 0; i < args.size(); i++) stmt.setObject(i+1, args.get(i));
|
|
return stmt.executeQuery();
|
|
} catch (SQLException sqle) { // add sql to the exception
|
|
throw new SQLException(t("Query '{}' failed:",this),sqle);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* build the final SQL and execure the actual request (for statements that generate no result)
|
|
* @throws SQLException
|
|
*/
|
|
public void run() throws SQLException {
|
|
LOG.debug("Running {}",this);
|
|
try {
|
|
var stmt = conn.prepareStatement(sql);
|
|
for (int i = 0; i < args.size(); i++) stmt.setObject(i+1, args.get(i));
|
|
stmt.execute();
|
|
} catch (SQLException sqle) {
|
|
throw new SQLException(t("Query '{}' failed:",this),sqle);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
var filled = sql;
|
|
int pos = filled.indexOf('?');
|
|
int idx = 0;
|
|
while (pos>0){
|
|
var fill = args.get(idx++);
|
|
var s = fill == null ? "NULL" : (fill instanceof Number ? ""+fill : "'"+fill+"'");
|
|
filled = filled.substring(0,pos)+s+filled.substring(pos+1);
|
|
pos = filled.indexOf('?',pos+s.length());
|
|
}
|
|
return filled;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* helper class to allow daisy-chaining request modifiers
|
|
*/
|
|
public class Request{
|
|
|
|
private String groupBy = null;
|
|
private final StringBuilder sql; // buffer the sql to be built
|
|
private final HashMap<String, List<Object>> where = new HashMap<>(); // buffer condition statements for select
|
|
private final HashMap<String,Object> values = new HashMap<>(); // buffer values for insert/update statements
|
|
private final List<String> sortFields = new ArrayList<>();
|
|
|
|
/**
|
|
* Start to create a new request with the initial SQL
|
|
* @param sql initial sql for the request
|
|
*/
|
|
public Request(StringBuilder sql) {
|
|
this.sql = sql;
|
|
}
|
|
|
|
/**
|
|
* conditions are assembled in the following form:
|
|
* SELECT … FROM … WHERE [key] in ([values])
|
|
* <values> are build as list of question marks, whose arguments are applied later
|
|
* @param args
|
|
*/
|
|
private void applyConditions(ArrayList<Object> args){
|
|
if (!where.isEmpty()){
|
|
var clauses = new ArrayList<String>();
|
|
sql.append(" WHERE ");
|
|
|
|
for (var entry : where.entrySet()){
|
|
{ // build the list of question marks
|
|
var arr = new String[entry.getValue().size()];
|
|
Arrays.fill(arr, "?");
|
|
var marks = String.join(", ", arr);
|
|
clauses.add("(" + entry.getKey() + " IN (" + marks + "))");
|
|
}
|
|
args.addAll(entry.getValue()); // will be applied to the prepared statement later
|
|
}
|
|
sql.append(String.join(" AND ",clauses));
|
|
}
|
|
}
|
|
|
|
private void applyGrouping(){
|
|
if (groupBy != null && !groupBy.isBlank()) sql.append(" GROUP BY ").append(groupBy.trim());
|
|
}
|
|
|
|
private void applySorting(){
|
|
if (!sortFields.isEmpty()) sql.append(" ORDER BY ").append(String.join(", ",sortFields));
|
|
}
|
|
|
|
/**
|
|
* apply values (for insert or update statements)
|
|
* @param args
|
|
*/
|
|
private void applyValues(ArrayList<Object> args){
|
|
if (values.isEmpty()) return;
|
|
var currentSql = sql();
|
|
if (currentSql.startsWith("INSERT")){
|
|
var keys = new ArrayList<String>();
|
|
for (var entry : values.entrySet()) {
|
|
keys.add(entry.getKey());
|
|
args.add(entry.getValue());
|
|
}
|
|
sql.append("(")
|
|
.append(String.join(", ",keys))
|
|
.append(")")
|
|
.append(" VALUES ");
|
|
var arr = new String[args.size()];
|
|
Arrays.fill(arr,"?");
|
|
var marks = String.join(", ",arr);
|
|
sql.append("(").append(marks).append(")");
|
|
}
|
|
if (currentSql.startsWith("UPDATE")){
|
|
var expressions = new ArrayList<String>();
|
|
for (var entry : values.entrySet()) {
|
|
expressions.add(entry.getKey()+" = ?");
|
|
args.add(entry.getValue());
|
|
}
|
|
sql.append(" SET ").append(String.join(", ",expressions));
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected Request clone() {
|
|
Request clone = new Request(new StringBuilder(sql));
|
|
clone.where.putAll(where);
|
|
clone.values.putAll(values);
|
|
return clone;
|
|
}
|
|
|
|
/**
|
|
* finalize sql, save sql and arguments as compiled request
|
|
* @return
|
|
*/
|
|
public CompiledRequest compile(Object ...additionalArgs){
|
|
var args = new ArrayList<>();
|
|
applyValues(args);
|
|
applyConditions(args);
|
|
applyGrouping();
|
|
applySorting();
|
|
if (additionalArgs != null) {
|
|
for (Object arg : additionalArgs) args.add(arg);
|
|
}
|
|
return new CompiledRequest(sql.toString(),args);
|
|
}
|
|
|
|
public Request groupBy(String column) {
|
|
groupBy = column;
|
|
return this;
|
|
}
|
|
|
|
public void run() throws SQLException {
|
|
compile().run();
|
|
}
|
|
|
|
/**
|
|
* set single value for a certain key
|
|
* @param key
|
|
* @param value
|
|
* @return
|
|
*/
|
|
public Request set(String key, Object value) {
|
|
values.put(key,value);
|
|
return this;
|
|
}
|
|
|
|
public Request sort(String field) {
|
|
sortFields.add(field);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* get the current (i.e. non-final) sql
|
|
* @return
|
|
*/
|
|
public String sql() {
|
|
return sql.toString();
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return "Request("+clone().compile()+')';
|
|
}
|
|
|
|
/**
|
|
* apply a map of values (for insert/update statements)
|
|
* @param newValues
|
|
* @return
|
|
*/
|
|
public Request values(Map<String,Object> newValues) {
|
|
values.putAll(newValues);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* add a where condition in the form of … WHERE [key] in ([values])
|
|
* @param key
|
|
* @param values
|
|
* @return
|
|
*/
|
|
public Request where(String key, Object ... values) {
|
|
for (var val : values) where(key,val);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* add a where condition in the form of … WHERE [key] in ([values])
|
|
* @param key
|
|
* @param values
|
|
* @return
|
|
*/
|
|
public Request where(String key, Collection<? extends Object> values) {
|
|
for (var val : values) where(key,val);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* add a where condition in the form of … WHERE [key] in ([value])
|
|
* @param key
|
|
* @param value
|
|
* @return
|
|
*/
|
|
private Request where(String key, Object value) {
|
|
var list = where.get(key);
|
|
if (list == null) where.put(key,list = new ArrayList<>());
|
|
list.add(value);
|
|
return this;
|
|
}
|
|
}
|
|
|
|
public Database(Connection connection) {
|
|
this.conn = connection;
|
|
}
|
|
|
|
/**
|
|
* take care of table creation, if required tables do not exist
|
|
* @return
|
|
* @throws SQLException
|
|
*/
|
|
private Database assertTables() throws SQLException {
|
|
if (!tableExists(User.TABLE_NAME)) User.createTable();
|
|
if (!tableExists(MailingList.TABLE_NAME)) MailingList.createTable();
|
|
if (!tableExists(ListMember.TABLE_NAME)) ListMember.createTable();
|
|
if (!tableExists(Post.TABLE_NAME)) Post.createTable();
|
|
if (!columnExists(MailingList.TABLE_NAME,HOLD_TIME)) MailingList.createHoldTimeColumn();
|
|
return this;
|
|
}
|
|
|
|
private void createVersionTable() throws SQLException {
|
|
var sql = "CREATE TABLE %s (%s %s NOT NULL PRIMARY KEY);".formatted(DB_VERSION,DB_VERSION,INT);
|
|
var db = Database.open();
|
|
db.query(sql).compile().run();
|
|
sql = "INSERT INTO %s VALUES (1)".formatted(DB_VERSION);
|
|
db.query(sql).compile().run();
|
|
}
|
|
|
|
private boolean columnExists(String tableName, String columnName) throws SQLException {
|
|
var rs = Database.open().select("pragma_table_info('"+tableName+"')","COUNT(*) AS num").where("name",columnName).compile().exec();
|
|
try {
|
|
if (rs.next()) return rs.getInt("num") > 0;
|
|
} finally {
|
|
rs.close();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public void createColumn(String tableName, String colName, String...typeArgs) throws SQLException {
|
|
var sql = new StringBuilder("ALTER TABLE ")
|
|
.append(tableName)
|
|
.append(" ADD COLUMN ")
|
|
.append(colName)
|
|
.append(" ")
|
|
.append(String.join(" ",typeArgs));
|
|
new Request(sql).compile().run();
|
|
}
|
|
|
|
/**
|
|
* prepare a deletion statement
|
|
* @param tableName
|
|
* @return
|
|
*/
|
|
public Request deleteFrom(String tableName){
|
|
return new Request(new StringBuilder("DELETE FROM ").append(tableName).append(" "));
|
|
}
|
|
|
|
/**
|
|
* prepare an insertion statement
|
|
* @param tableName
|
|
* @return
|
|
*/
|
|
public Request insertInto(String tableName){
|
|
return new Request(new StringBuilder("INSERT INTO ").append(tableName).append(" "));
|
|
}
|
|
|
|
/**
|
|
* check if we have an active db connection
|
|
* @return
|
|
*/
|
|
public boolean isOpen() {
|
|
return conn instanceof Connection;
|
|
}
|
|
|
|
/**
|
|
* Open the database, if it is not open.
|
|
* Will create the singleton on first call.
|
|
* Consecutive calls will just return the singleton.
|
|
* @return
|
|
*/
|
|
public static Database open() {
|
|
if (singleton == null){
|
|
Configuration config = Configuration.instance();
|
|
var dbFile = config.dbFile();
|
|
String url = "jdbc:sqlite:"+dbFile;
|
|
LOG.debug("Opening {}",url);
|
|
dbFile.getParentFile().mkdirs();
|
|
try {
|
|
singleton = new Database(DriverManager.getConnection(url));
|
|
singleton.assertTables(); // must not be concatenated to exception above (assertTables accesses singleton)!
|
|
singleton.update202405();
|
|
} catch (SQLException sqle) {
|
|
sqle.printStackTrace();
|
|
}
|
|
}
|
|
return singleton;
|
|
}
|
|
|
|
/**
|
|
* Create a query from a pre-set StringBuilder
|
|
* @param sql
|
|
* @return
|
|
*/
|
|
public Request query(StringBuilder sql) {
|
|
return new Request(sql);
|
|
}
|
|
|
|
public Request query(String sql) {
|
|
return new Request(new StringBuilder(sql));
|
|
}
|
|
|
|
/**
|
|
* create a SELECT [flields] FROM [table] request.
|
|
* If no fields are supplied, a request in the form SELECT * FROM [table] will be generated.
|
|
* @param tableName
|
|
* @param fields
|
|
* @return
|
|
*/
|
|
public Request select(String tableName,String ... fields) {
|
|
StringBuilder sql = new StringBuilder("SELECT ");
|
|
if (fields == null || fields.length == 0){
|
|
sql.append("*");
|
|
} else {
|
|
sql.append(String.join(", ",fields));
|
|
}
|
|
return new Request(sql.append(" FROM ").append(tableName));
|
|
}
|
|
|
|
/**
|
|
* check, whether a table with the provided name exists
|
|
* @param tbName
|
|
* @return
|
|
* @throws SQLException
|
|
*/
|
|
public boolean tableExists(String tbName) throws SQLException {
|
|
try {
|
|
var sql = new StringBuilder("SELECT EXISTS (SELECT name FROM sqlite_schema WHERE type='table' AND name='")
|
|
.append(tbName)
|
|
.append("')");
|
|
ResultSet rs = query(sql).compile().exec();
|
|
int val = 0;
|
|
if (rs.next()) val = rs.getInt(1);
|
|
rs.close();
|
|
return val > 0;
|
|
} catch (SQLException e) {
|
|
throw new SQLException(t("Was not able to check existence of table {}!",tbName),e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* create an UPDATE [tableName] query
|
|
* @param tableName
|
|
* @return
|
|
*/
|
|
public Request update(String tableName) {
|
|
return new Request(new StringBuilder("UPDATE ").append(tableName));
|
|
}
|
|
|
|
private Database update202405() throws SQLException {
|
|
if (!tableExists(Database.DB_VERSION)) {
|
|
createVersionTable();
|
|
User.addTokenColumn();
|
|
}
|
|
return this;
|
|
}
|
|
}
|