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 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 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> where = new HashMap<>(); // buffer condition statements for select private final HashMap values = new HashMap<>(); // buffer values for insert/update statements private final List 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]) * are build as list of question marks, whose arguments are applied later * @param args */ private void applyConditions(ArrayList args){ if (!where.isEmpty()){ var clauses = new ArrayList(); 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 args){ if (values.isEmpty()) return; var currentSql = sql(); if (currentSql.startsWith("INSERT")){ var keys = new ArrayList(); 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(); 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 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 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; } }