/* * Kontalk Java client * Copyright (C) 2016 Kontalk Devteam <[email protected]> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * 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 * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kontalk.persistence; import java.nio.file.Path; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.sql.Types; import java.util.ArrayList; import java.util.Date; import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import org.apache.commons.lang.StringUtils; import org.kontalk.misc.JID; import org.kontalk.misc.KonException; import org.kontalk.model.message.KonMessage; import org.kontalk.model.chat.Chat; import org.kontalk.model.Contact; import org.kontalk.model.chat.Member; import org.kontalk.model.message.Transmission; import org.kontalk.util.EncodingUtils; import org.sqlite.SQLiteConfig; /** * Global database for permanently storing all model information. * Uses the JDBC API and SQLite as DBMS. * * Database access is not concurrent safe (connection pool is needed). At least * writing is synchronized. Hopefully we don't see this no more: * "SQLException: ResultSet already requested" or "ResultSet closed" * * @author Alexander Bikadorov {@literal <[email protected]>} */ public final class Database { private static final Logger LOGGER = Logger.getLogger(Database.class.getName()); public static final String SQL_ID = "_id INTEGER PRIMARY KEY AUTOINCREMENT, "; private static final String FILENAME = "kontalk_db.sqlite"; private static final int DB_VERSION = 5; private static final String SQL_CREATE = "CREATE TABLE IF NOT EXISTS "; private static final String SV = "schema_version"; private static final String UV = "user_version"; private Connection mConn = null; public Database(Path appDir) throws KonException { // load the sqlite-JDBC driver using the current class loader try { Class.forName("org.sqlite.JDBC"); } catch (ClassNotFoundException ex) { LOGGER.log(Level.SEVERE, "sqlite-JDBC driver not found", ex); throw new KonException(KonException.Error.DB, ex); } // create database connection Path path = appDir.resolve(FILENAME); SQLiteConfig config = new SQLiteConfig(); config.enforceForeignKeys(true); try { mConn = DriverManager.getConnection("jdbc:sqlite:" + path.toString(), config.toProperties()); } catch(SQLException ex) { // if the error message is "out of memory", // it probably means no database file is found LOGGER.log(Level.SEVERE, "can't create database connection", ex); throw new KonException(KonException.Error.DB, ex); } try { // setting to false! mConn.setAutoCommit(false); } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't set autocommit", ex); } boolean isNew; try (ResultSet rs = this.execQuery("PRAGMA "+SV)) { isNew = rs.getInt(SV) == 0; } catch (SQLException ex) { LOGGER.log(Level.SEVERE, "can't get schema version", ex); throw new KonException(KonException.Error.DB, ex); } if (isNew) { LOGGER.info("new database, creating tables"); try (Statement stat = mConn.createStatement()) { // set version mConn.createStatement().execute("PRAGMA "+UV+" = "+DB_VERSION); this.commit(); this.createTable(stat, Contact.TABLE, Contact.SCHEMA); this.createTable(stat, Chat.TABLE, Chat.SCHEMA); this.createTable(stat, Member.TABLE, Member.SCHEMA); this.createTable(stat, KonMessage.TABLE, KonMessage.SCHEMA); this.createTable(stat, Transmission.TABLE, Transmission.SCHEMA); } catch (SQLException ex) { LOGGER.log(Level.SEVERE, "can't create tables", ex); throw new KonException(KonException.Error.DB, ex); } return; } // update if needed int version; try (ResultSet rs = this.execQuery("PRAGMA "+UV)) { version = rs.getInt(UV); } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't get db version", ex); return; } LOGGER.config("version: "+version); if (version < DB_VERSION) { try { this.update(version); } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't update db", ex); } } } private void createTable(Statement stat, String table, String schema) throws SQLException { stat.executeUpdate(SQL_CREATE + table + " " + schema); } private void update(int fromVersion) throws SQLException { if (fromVersion < 1) { mConn.createStatement().execute("ALTER TABLE "+Chat.TABLE+ " ADD COLUMN "+Chat.COL_VIEW_SET+" NOT NULL DEFAULT '{}'"); } if (fromVersion < 2) { mConn.createStatement().execute("ALTER TABLE "+KonMessage.TABLE+ " ADD COLUMN "+KonMessage.COL_SERV_DATE+" DEFAULT NULL"); } if (fromVersion < 3) { String messageTableTemp = KonMessage.TABLE + "_TEMP"; this.createTable(mConn.createStatement(), messageTableTemp, KonMessage.SCHEMA); mConn.createStatement().execute("INSERT INTO "+messageTableTemp + " SELECT _id, thread_id, xmpp_id, date, receipt_status, " + "content, encryption_status, signing_status, coder_errors, " + "server_error, server_date FROM "+KonMessage.TABLE); this.createTable(mConn.createStatement(), Transmission.TABLE, Transmission.SCHEMA); mConn.createStatement().execute("INSERT INTO "+Transmission.TABLE + " SELECT NULL, _id, user_id, jid, NULL FROM "+KonMessage.TABLE); mConn.createStatement().execute("PRAGMA foreign_keys=OFF"); mConn.createStatement().execute("DROP TABLE "+KonMessage.TABLE); mConn.createStatement().execute("ALTER TABLE "+messageTableTemp+ " RENAME TO "+KonMessage.TABLE); mConn.createStatement().execute("PRAGMA foreign_keys=ON"); mConn.createStatement().execute("ALTER TABLE "+Chat.TABLE+ " ADD COLUMN "+Chat.COL_GD+" DEFAULT NULL"); } if (fromVersion < 4) { mConn.createStatement().execute("ALTER TABLE "+Contact.TABLE+ " ADD COLUMN "+Contact.COL_AVATAR_ID+" DEFAULT NULL"); } if (fromVersion < 5) { mConn.createStatement().execute("ALTER TABLE "+Member.TABLE+ " ADD COLUMN "+Member.COL_ROLE+" DEFAULT 0"); } // set new version mConn.createStatement().execute("PRAGMA "+UV+" = "+DB_VERSION); this.commit(); LOGGER.info("updated to version "+DB_VERSION); } public synchronized void close() { try { if(mConn == null || mConn.isClosed()) return; // just to be sure mConn.commit(); mConn.close(); } catch(SQLException ex) { LOGGER.log(Level.WARNING, "can't close db", ex); } } /** * Select all rows from one table. * The returned ResultSet must be closed by the caller after usage! */ public ResultSet execSelectAll(String table) throws SQLException { return this.execQuery("SELECT * FROM " + table); } /** * Select rows from one table that match an arbitrary 'where' clause. * Insecure to SQL injections, use with caution! * The returned ResultSet must be closed by the caller after usage! */ public ResultSet execSelectWhereInsecure(String table, String where) throws SQLException { return this.execQuery("SELECT * FROM " + table + " WHERE " + where); } private ResultSet execQuery(String select) throws SQLException { try { PreparedStatement stat = mConn.prepareStatement(select); // does not work, i dont care //stat.closeOnCompletion(); ResultSet resultSet = stat.executeQuery(); return resultSet; } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't execute select: " + select, ex); throw ex; } } /** * Add a new model / row to database. * @param table table name the values are inserted into * @param values all objects / row fields that to insert * @return id value of inserted row, -1 if something went wrong */ public synchronized int execInsert(String table, List<Object> values) { // first column is the id String insert = "INSERT INTO " + table + " VALUES (NULL,"; List<String> vList = new ArrayList<>(values.size()); while(vList.size() < values.size()) vList.add("?"); insert += StringUtils.join(vList, ", ") + ")"; try (PreparedStatement stat = mConn.prepareStatement(insert, Statement.RETURN_GENERATED_KEYS)) { insertValues(stat, values); stat.executeUpdate(); mConn.commit(); ResultSet keys = stat.getGeneratedKeys(); return keys.getInt(1); } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't execute insert: " + insert + " " + values, ex); return -1; } } /** Update values (at most one row). */ public synchronized void execUpdate(String table, Map<String, Object> set, int id) { LOGGER.config("table: "+table); String update = "UPDATE OR FAIL " + table + " SET "; List<String> keyList = new ArrayList<>(set.keySet()); List<String> vList = keyList.stream() .map(key -> key + " = ?") .collect(Collectors.toList()); update += StringUtils.join(vList, ", ") + " WHERE _id == " + id ; // note: looks like driver doesn't support "LIMIT" //update += " LIMIT 1"; try (PreparedStatement stat = mConn.prepareStatement(update, Statement.RETURN_GENERATED_KEYS)) { insertValues(stat, keyList, set); stat.executeUpdate(); mConn.commit(); stat.getGeneratedKeys(); } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't execute update: " + update + " " + set, ex); } } /** Delete one row. Not commited! Call commit() after deletions. */ public boolean execDelete(String table, int id) { LOGGER.info("deletion, table: " + table + "; id: " + id); try (Statement stat = mConn.createStatement()) { stat.executeUpdate("DELETE FROM " + table + " WHERE _id = " + id); } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't delete", ex); return false; } return true; } public boolean commit() { try { mConn.commit(); } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't commit", ex); return false; } return true; } private static void insertValues(PreparedStatement stat, List<String> keys, Map<String, Object> map) throws SQLException { for (int i = 0; i < keys.size(); i++) { setValue(stat, i, map.get(keys.get(i))); } } private static void insertValues(PreparedStatement stat, List<Object> values) throws SQLException { for (int i = 0; i < values.size(); i++) { setValue(stat, i, values.get(i)); } } private static void setValue(PreparedStatement stat, int i, Object value) throws SQLException { if (value instanceof String) { stat.setString(i+1, (String) value); } else if (value instanceof Integer) { stat.setInt(i+1, (int) value); } else if (value instanceof Date) { stat.setLong(i+1, ((Date) value).getTime()); } else if (value instanceof Boolean) { stat.setBoolean(i+1, (boolean) value); } else if (value instanceof Enum) { stat.setInt(i+1, ((Enum) value).ordinal()); } else if (value instanceof EnumSet) { stat.setInt(i+1, EncodingUtils.enumSetToInt(((EnumSet) value))); } else if (value instanceof Optional) { setValue(stat, i, ((Optional<?>) value).orElse(null)); } else if (value instanceof JID) { stat.setString(i+1, ((JID) value).string()); } else if (value == null) { stat.setNull(i+1, Types.NULL); } else { LOGGER.warning("unknown type: " + value); } } /** * Return the value for a specific column as string; the string is empty if * the value is SQL NULL. */ public static String getString(ResultSet r, String columnLabel){ String s; try { s = r.getString(columnLabel); } catch (SQLException ex) { LOGGER.log(Level.WARNING, "can't get string from db", ex); return ""; } return StringUtils.defaultString(s); } public static String setString(String s) { return s.isEmpty() ? null : s; } }