From 2521806ee6ae0a02efcacd8b3533a01ecb5dbf14 Mon Sep 17 00:00:00 2001 From: Robert Adam Date: Thu, 10 Feb 2022 20:10:49 +0100 Subject: [PATCH] FEAT(server): Implement new database backend This commit implements a new, Qt-independent mechanism for accessing and handling a database. It fully supports SQLite, MySQL and PostgreSQL. --- .../install_ubuntu_shared_64bit.sh | 32 +- .github/workflows/build.yml | 9 +- .gitmodules | 3 + 3rdparty/soci | 1 + cmake/FindModules/FindSoci.cmake | 109 +++ src/CMakeLists.txt | 3 + src/NonCopyable.h | 20 + src/database/AccessException.h | 24 + src/database/Backend.cpp | 44 ++ src/database/Backend.h | 53 ++ src/database/CMakeLists.txt | 87 +++ src/database/Column.cpp | 49 ++ src/database/Column.h | 48 ++ src/database/ConnectionParameter.h | 26 + src/database/Constants.h | 19 + src/database/Constraint.cpp | 216 ++++++ src/database/Constraint.h | 59 ++ src/database/DBUtils.cpp | 84 +++ src/database/DBUtils.h | 30 + src/database/DataType.cpp | 158 +++++ src/database/DataType.h | 59 ++ src/database/Database.cpp | 525 ++++++++++++++ src/database/Database.h | 95 +++ src/database/Exception.h | 24 + src/database/FormatException.h | 24 + src/database/Index.cpp | 64 ++ src/database/Index.h | 41 ++ src/database/InitException.h | 24 + src/database/MetaTable.cpp | 50 ++ src/database/MetaTable.h | 27 + src/database/MySQLConnectionParameter.cpp | 20 + src/database/MySQLConnectionParameter.h | 33 + src/database/NoDataException.h | 23 + .../PostgreSQLConnectionParameter.cpp | 20 + src/database/PostgreSQLConnectionParameter.h | 34 + src/database/SQLiteConnectionParameter.cpp | 25 + src/database/SQLiteConnectionParameter.h | 31 + src/database/TODO | 1 + src/database/Table.cpp | 418 ++++++++++++ src/database/Table.h | 82 +++ src/database/Trigger.cpp | 102 +++ src/database/Trigger.h | 61 ++ src/database/UnsupportedOperationException.h | 24 + src/database/Utils.h | 33 + src/database/Version.cpp | 42 ++ src/database/Version.h | 35 + src/mumble/CMakeLists.txt | 16 +- src/tests/CMakeLists.txt | 1 + src/tests/TestDatabase/CMakeLists.txt | 28 + src/tests/TestDatabase/ConstraintTable.cpp | 180 +++++ src/tests/TestDatabase/ConstraintTable.h | 50 ++ src/tests/TestDatabase/DatabaseTest.cpp | 646 ++++++++++++++++++ src/tests/TestDatabase/DefaultTable.cpp | 53 ++ src/tests/TestDatabase/DefaultTable.h | 38 ++ src/tests/TestDatabase/KeyValueTable.cpp | 52 ++ src/tests/TestDatabase/KeyValueTable.h | 35 + src/tests/TestDatabase/README.md | 61 ++ src/tests/TestDatabase/TestUtils.cpp | 88 +++ src/tests/TestDatabase/TestUtils.h | 91 +++ 59 files changed, 4320 insertions(+), 10 deletions(-) create mode 160000 3rdparty/soci create mode 100644 cmake/FindModules/FindSoci.cmake create mode 100644 src/NonCopyable.h create mode 100644 src/database/AccessException.h create mode 100644 src/database/Backend.cpp create mode 100644 src/database/Backend.h create mode 100644 src/database/CMakeLists.txt create mode 100644 src/database/Column.cpp create mode 100644 src/database/Column.h create mode 100644 src/database/ConnectionParameter.h create mode 100644 src/database/Constants.h create mode 100644 src/database/Constraint.cpp create mode 100644 src/database/Constraint.h create mode 100644 src/database/DBUtils.cpp create mode 100644 src/database/DBUtils.h create mode 100644 src/database/DataType.cpp create mode 100644 src/database/DataType.h create mode 100644 src/database/Database.cpp create mode 100644 src/database/Database.h create mode 100644 src/database/Exception.h create mode 100644 src/database/FormatException.h create mode 100644 src/database/Index.cpp create mode 100644 src/database/Index.h create mode 100644 src/database/InitException.h create mode 100644 src/database/MetaTable.cpp create mode 100644 src/database/MetaTable.h create mode 100644 src/database/MySQLConnectionParameter.cpp create mode 100644 src/database/MySQLConnectionParameter.h create mode 100644 src/database/NoDataException.h create mode 100644 src/database/PostgreSQLConnectionParameter.cpp create mode 100644 src/database/PostgreSQLConnectionParameter.h create mode 100644 src/database/SQLiteConnectionParameter.cpp create mode 100644 src/database/SQLiteConnectionParameter.h create mode 100644 src/database/TODO create mode 100644 src/database/Table.cpp create mode 100644 src/database/Table.h create mode 100644 src/database/Trigger.cpp create mode 100644 src/database/Trigger.h create mode 100644 src/database/UnsupportedOperationException.h create mode 100644 src/database/Utils.h create mode 100644 src/database/Version.cpp create mode 100644 src/database/Version.h create mode 100644 src/tests/TestDatabase/CMakeLists.txt create mode 100644 src/tests/TestDatabase/ConstraintTable.cpp create mode 100644 src/tests/TestDatabase/ConstraintTable.h create mode 100644 src/tests/TestDatabase/DatabaseTest.cpp create mode 100644 src/tests/TestDatabase/DefaultTable.cpp create mode 100644 src/tests/TestDatabase/DefaultTable.h create mode 100644 src/tests/TestDatabase/KeyValueTable.cpp create mode 100644 src/tests/TestDatabase/KeyValueTable.h create mode 100644 src/tests/TestDatabase/README.md create mode 100644 src/tests/TestDatabase/TestUtils.cpp create mode 100644 src/tests/TestDatabase/TestUtils.h diff --git a/.github/actions/install-dependencies/install_ubuntu_shared_64bit.sh b/.github/actions/install-dependencies/install_ubuntu_shared_64bit.sh index 74d2e2e0d..96128dfd1 100755 --- a/.github/actions/install-dependencies/install_ubuntu_shared_64bit.sh +++ b/.github/actions/install-dependencies/install_ubuntu_shared_64bit.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash +set -e +set -x + sudo apt update sudo apt -y install \ @@ -29,4 +32,31 @@ sudo apt -y install \ libzeroc-ice-dev \ zsync \ appstream \ - libpoco-dev + libpoco-dev \ + libsqlite3-dev + +# MySQL and PostgreSQL are pre-installed on GitHub-hosted runners. More info about the default setup can be found at +# - https://github.com/actions/virtual-environments/blob/main/images/linux/Ubuntu1804-Readme.md#databases +# - https://github.com/actions/virtual-environments/blob/main/images/linux/Ubuntu2004-Readme.md#databases + +# Setup MySQL and PostgreSQL databases for the Mumble tests +echo "Configuring MySQL..." + +echo -e "[mysqld]\nlog-bin-trust-function-creators = 1" | sudo tee -a /etc/mysql/my.cnf + +sudo systemctl enable mysql.service +sudo systemctl start mysql.service + +echo "CREATE DATABASE mumble_test_db; "\ + "CREATE USER 'mumble_test_user'@'localhost' IDENTIFIED BY 'MumbleTestPassword'; "\ + "GRANT ALL PRIVILEGES ON mumble_test_db.* TO 'mumble_test_user'@'localhost';" | sudo mysql --user=root --password="root" + + +echo "Configuring PostgreSQL..." + +sudo systemctl enable postgresql.service +sudo systemctl start postgresql.service + +echo "CREATE DATABASE mumble_test_db; "\ + "CREATE USER mumble_test_user ENCRYPTED PASSWORD 'MumbleTestPassword'; "\ + "GRANT ALL PRIVILEGES ON DATABASE mumble_test_db TO mumble_test_user;" | sudo -u postgres psql diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 79206c8b8..69f6fa374 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,8 +5,7 @@ on: [push, pull_request] env: # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) BUILD_TYPE: Release - CMAKE_OPTIONS: | - -Dtests=ON -Dsymbols=ON -Ddisplay-install-paths=ON + CMAKE_OPTIONS: -Dtests=ON -Dsymbols=ON -Ddisplay-install-paths=ON -Ddatabase-mysql-tests=ON -Ddatabase-postgresql-tests=ON MUMBLE_ENVIRONMENT_SOURCE: 'https://dl.mumble.info/build/vcpkg/' @@ -58,6 +57,12 @@ jobs: shell: bash + # Ubuntu 18.04 does not ship a recent enough MySQL version, so we have to skip the MySQL tests there + - name: Disable MySQL tests on Ubuntu 18.04 + if: ${{ matrix.os == 'ubuntu-18.04' }} + run: echo "CMAKE_OPTIONS=${{ env.CMAKE_OPTIONS }} -Ddatabase-mysql-tests=OFF" >> $GITHUB_ENV + shell: bash + - uses: actions/checkout@v4 with: submodules: 'recursive' diff --git a/.gitmodules b/.gitmodules index 363fafb49..009b62dec 100644 --- a/.gitmodules +++ b/.gitmodules @@ -34,3 +34,6 @@ [submodule "3rdparty/utfcpp"] path = 3rdparty/utfcpp url = https://github.com/mumble-voip/utfcpp.git +[submodule "3rdparty/soci"] + path = 3rdparty/soci + url = https://github.com/SOCI/soci.git diff --git a/3rdparty/soci b/3rdparty/soci new file mode 160000 index 000000000..438e35495 --- /dev/null +++ b/3rdparty/soci @@ -0,0 +1 @@ +Subproject commit 438e3549594eb59d84b434c814647648e7c2f10a diff --git a/cmake/FindModules/FindSoci.cmake b/cmake/FindModules/FindSoci.cmake new file mode 100644 index 000000000..865b03f03 --- /dev/null +++ b/cmake/FindModules/FindSoci.cmake @@ -0,0 +1,109 @@ +# Adapted from https://github.com/SOCI/soci/blob/master/cmake/modules/FindSoci.cmake +############################################################################### +# CMake module to search for SOCI library +# +# This module defines: +# Soci_INCLUDE_DIRS = include dirs to be used when using the soci library +# Soci_LIBRARY = full path to the soci library +# Soci_VERSION = the soci version found +# Soci_FOUND = true if soci was found +# +# This module respects: +# LIB_SUFFIX = (64|32|"") Specifies the suffix for the lib directory +# +# For each component you specify in find_package(), the following variables are set. +# +# Soci_${COMPONENT}_PLUGIN = full path to the soci plugin (not set for the "core" component) +# Soci_${COMPONENT}_FOUND +# +# Redistribution and use is allowed according to the terms of the BSD license. +# For details see the accompanying COPYING-CMAKE-SCRIPTS file. +# +############################################################################### +# +### Global Configuration Section +# +SET(_SOCI_ALL_PLUGINS mysql odbc postgresql sqlite3) +SET(_SOCI_REQUIRED_VARS Soci_INCLUDE_DIR Soci_LIBRARY) + +# +### FIRST STEP: Find the soci headers. +# +FIND_PATH( + Soci_INCLUDE_DIR soci.h + HINTS "/usr/local" + PATH_SUFFIXES "" "soci" + DOC "Soci (http://soci.sourceforge.net) include directory") +MARK_AS_ADVANCED(SOCI_INCLUDE_DIR) + +SET(Soci_INCLUDE_DIRS ${Soci_INCLUDE_DIR} CACHE STRING "") + +# +### SECOND STEP: Find the soci core library. Respect LIB_SUFFIX +# +FIND_LIBRARY( + Soci_LIBRARY + NAMES soci_core + HINTS ${Soci_INCLUDE_DIR}/.. + PATH_SUFFIXES lib${LIB_SUFFIX}) +MARK_AS_ADVANCED(SOCI_LIBRARY) + +GET_FILENAME_COMPONENT(Soci_LIBRARY_DIR ${Soci_LIBRARY} PATH) +MARK_AS_ADVANCED(Soci_LIBRARY_DIR) + +# +### THIRD STEP: Find all installed plugins if the library was found +# +IF(Soci_INCLUDE_DIR AND Soci_LIBRARY) + SET(Soci_core_FOUND TRUE CACHE BOOL "") + + # + ### FOURTH STEP: Obtain SOCI version + # + set(Soci_VERSION_FILE "${SOCI_INCLUDE_DIR}/version.h") + IF(EXISTS "${Soci_VERSION_FILE}") + file(READ "${Soci_VERSION_FILE}" VERSION_CONTENT) + string(REGEX MATCH "#define[ \t]*SOCI_VERSION[ \t]*[0-9]+" VERSION_MATCH "${VERSION_CONTENT}") + string(REGEX REPLACE "#define[ \t]*SOCI_VERSION[ \t]*" "" VERSION_MATCH "${VERSION_MATCH}") + + IF(NOT VERSION_MATCH) + message(WARNING "Failed to extract SOCI version") + ELSE() + math(EXPR MAJOR "${VERSION_MATCH} / 100000" OUTPUT_FORMAT DECIMAL) + math(EXPR MINOR "${VERSION_MATCH} / 100 % 1000" OUTPUT_FORMAT DECIMAL) + math(EXPR PATCH "${VERSION_MATCH} % 100" OUTPUT_FORMAT DECIMAL) + + set(Soci_VERSION "${MAJOR}.${MINOR}.${PATCH}" CACHE STRING "") + ENDIF() + ELSE() + message(WARNING "Unable to check SOCI version") + ENDIF() + + FOREACH(plugin IN LISTS _SOCI_ALL_PLUGINS) + + FIND_LIBRARY( + Soci_${plugin}_PLUGIN + NAMES soci_${plugin} + HINTS ${Soci_INCLUDE_DIR}/.. + PATH_SUFFIXES lib${LIB_SUFFIX}) + MARK_AS_ADVANCED(Soci_${plugin}_PLUGIN) + + IF(Soci_${plugin}_PLUGIN) + SET(Soci_${plugin}_FOUND TRUE CACHE BOOL "") + ELSE() + SET(Soci_${plugin}_FOUND FALSE CACHE BOOL "") + ENDIF() + + ENDFOREACH() + +ENDIF() + +# +### ADHERE TO STANDARDS +# +include(FindPackageHandleStandardArgs) +FIND_PACKAGE_HANDLE_STANDARD_ARGS(Soci + REQUIRED_VARS ${_SOCI_REQUIRED_VARS} + VERSION_VAR Soci_VERSION + HANDLE_COMPONENTS +) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f1b7cb02d..b05318b86 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -15,6 +15,7 @@ option(zeroconf "Build support for zeroconf (mDNS/DNS-SD)." ON) option(tracy "Enable the tracy profiler." OFF) option(bundled-gsl "Use the bundled GSL version instead of looking for one on the system" ON) +option(bundled-json "Build the included version of nlohmann_json instead of looking for one on the system" ON) find_pkg(Qt5 @@ -245,6 +246,8 @@ if(client) endif() endif() +add_subdirectory(database) + if(server) add_subdirectory(murmur) endif() diff --git a/src/NonCopyable.h b/src/NonCopyable.h new file mode 100644 index 000000000..85e131590 --- /dev/null +++ b/src/NonCopyable.h @@ -0,0 +1,20 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_NONCOPYABLE_H_ +#define MUMBLE_NONCOPYABLE_H_ + +/* + * Helper class whose only property is that it can't be copied. Intended + * to be used as superclass to inherit this behavior. + */ +struct NonCopyable { + NonCopyable() = default; + + NonCopyable(const NonCopyable &) = delete; + NonCopyable &operator=(const NonCopyable &) = delete; +}; + +#endif // MUMBLE_NONCOPYABLE_H_ diff --git a/src/database/AccessException.h b/src/database/AccessException.h new file mode 100644 index 000000000..acbe5dd11 --- /dev/null +++ b/src/database/AccessException.h @@ -0,0 +1,24 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_ACCESSEXCEPTION_H_ +#define MUMBLE_DATABASE_ACCESSEXCEPTION_H_ + +#include "Exception.h" + +namespace mumble { +namespace db { + + /** + * Thrown whenever accessing the database results in an error + */ + struct AccessException : public Exception { + using Exception::Exception; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_ACCESSEXCEPTION_H_ diff --git a/src/database/Backend.cpp b/src/database/Backend.cpp new file mode 100644 index 000000000..6b303e045 --- /dev/null +++ b/src/database/Backend.cpp @@ -0,0 +1,44 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "Backend.h" + +#include + +#include + +namespace mumble { +namespace db { + + Backend stringToBackend(const std::string &str) { + if (boost::iequals(str, "sqlite")) { + return Backend::SQLite; + } else if (boost::iequals(str, "mysql")) { + return Backend::MySQL; + } else if (boost::iequals(str, "postgresql")) { + return Backend::PostgreSQL; + } + + throw BadBackendException(std::string("Unknown backend \"") + str + "\""); + } + + std::string backendToString(Backend backend) { + switch (backend) { + case Backend::SQLite: + return "SQLite"; + case Backend::MySQL: + return "MySQL"; + case Backend::PostgreSQL: + return "PostgreSQL"; + } + + // This code shouldn't be reached under normal circumstances + assert(false && "Reached unreachable code path"); + throw BadBackendException(std::string("Invalid value for Backend enum: ") + + std::to_string(static_cast< std::underlying_type_t< Backend > >(backend))); + } + +} // namespace db +} // namespace mumble diff --git a/src/database/Backend.h b/src/database/Backend.h new file mode 100644 index 000000000..f71d2134c --- /dev/null +++ b/src/database/Backend.h @@ -0,0 +1,53 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_BACKEND_H_ +#define MUMBLE_DATABASE_BACKEND_H_ + +#include "Exception.h" + +#include + +namespace mumble { +namespace db { + + /** + * An enum holding the different supported backend types + */ + enum class Backend { + SQLite, + MySQL, + PostgreSQL, + }; + + /** + * An exception thrown if something goes wrong during Backend <-> string conversions + */ + class BadBackendException : public Exception { + public: + // Re-use all constructors of std::runtime_error + using Exception::Exception; + }; + + /** + * Converts the given string into a Backend enum value. The string matching is performed case-insensitively. + * + * @param str The string to convert + * @returns The Backend corresponding to the given string + */ + Backend stringToBackend(const std::string &str); + + /** + * Converts the given Backend value into a string representation. + * + * @param backend The Backend to convert + * @returns The corresponding string representation + */ + std::string backendToString(Backend backend); + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_BACKEND_H_ diff --git a/src/database/CMakeLists.txt b/src/database/CMakeLists.txt new file mode 100644 index 000000000..981d182e8 --- /dev/null +++ b/src/database/CMakeLists.txt @@ -0,0 +1,87 @@ +# Copyright 2022 The Mumble Developers. All rights reserved. +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE file at the root of the +# Mumble source tree or at . + +option(bundled-soci "Build the included version of SOCI instead of looking for one on the system" ON) + +add_library(mumble_database STATIC + "Backend.cpp" + "Column.cpp" + "Constraint.cpp" + "DBUtils.cpp" + "DataType.cpp" + "Database.cpp" + "Index.cpp" + "MetaTable.cpp" + "MySQLConnectionParameter.cpp" + "PostgreSQLConnectionParameter.cpp" + "SQLiteConnectionParameter.cpp" + "Table.cpp" + "Trigger.cpp" + "Version.cpp" +) + +target_include_directories(mumble_database PUBLIC "${CMAKE_SOURCE_DIR}/src") + +if (bundled-soci) + # Include SOCI, but hard-code a few options + set(SOCI_BINARY_DIR ${CMAKE_CURRENT_BINARY_DIR}/soci) + + set(SOCI_SHARED ON CACHE BOOL "" FORCE) + set(SOCI_STATIC ON CACHE BOOL "" FORCE) + set(SOCI_TESTS OFF CACHE BOOL "" FORCE) + set(SOCI_LTO ${lto} CACHE BOOL "" FORCE) + set(SOCI_DB2 OFF CACHE BOOL "" FORCE) + set(SOCI_FIREBIRD OFF CACHE BOOL "" FORCE) + set(SOCI_ODBC OFF CACHE BOOL "" FORCE) + set(SOCI_ORACLE OFF CACHE BOOL "" FORCE) + set(SOCI_EMPTY OFF CACHE BOOL "" FORCE) + set(SOCI_MYSQL ON CACHE BOOL "" FORCE) + set(SOCI_POSTGRESQL ON CACHE BOOL "" FORCE) + set(SOCI_SQLITE3 ON CACHE BOOL "" FORCE) + + add_subdirectory("${3RDPARTY_DIR}/soci" "${SOCI_BINARY_DIR}" EXCLUDE_FROM_ALL) + + disable_warnings_for_all_targets_in("${3RDPARTY_DIR}/soci") + + # SOCI's built include/ directory has to be added to the project explicitly. + # This is needed so that SOCI can find the file soci/soci-config.h that gets generated by CMake. + target_include_directories(mumble_database + PUBLIC + ${SOCI_BINARY_DIR}/include + ) + + set(soci_components + "soci_mysql_static" + "soci_postgresql_static" + "soci_sqlite3_static" + "soci_core_static" + ) +else() + find_pkg("Soci" 4.0.0 COMPONENTS mysql postgresql sqlite3 core REQUIRED) + + set(soci_static "") + set(soci_components + "${Soci_mysql_PLUGIN}" + "${Soci_postgresql_PLUGIN}" + "${Soci_sqlite3_PLUGIN}" + "${Soci_LIBRARY}" + ) +endif() + + +target_link_libraries(mumble_database PUBLIC ${soci_components}) + + +if (NOT TARGET nlohmann_json) + if(bundled-json) + set(JSON_BuildTests OFF CACHE INTERNAL "") + set(JSON_ImplicitConversions OFF CACHE INTERNAL "") + add_subdirectory("${3RDPARTY_DIR}/nlohmann_json/" "${CMAKE_CURRENT_BINARY_DIR}/nlohmann_json/" EXCLUDE_FROM_ALL) + else() + find_pkg("nlohmann_json" REQUIRED) + endif() +endif() + +target_link_libraries(mumble_database PUBLIC nlohmann_json) diff --git a/src/database/Column.cpp b/src/database/Column.cpp new file mode 100644 index 000000000..424e5f1d5 --- /dev/null +++ b/src/database/Column.cpp @@ -0,0 +1,49 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "Column.h" + +#include + +#include + +namespace mumble { +namespace db { + + Column::Column(const std::string &name, const DataType &type, const std::vector< Constraint > &constraints, + const std::string &defaultValue) + : m_type(type), m_constraints(constraints), m_default(defaultValue) { + setName(name); + } + + const std::string Column::getName() const { return m_name; } + + void Column::setName(const std::string &name) { + assert(!boost::contains(name, " ")); + + m_name = name; + } + + const DataType &Column::getType() const { return m_type; } + + void Column::setType(const DataType &type) { m_type = type; } + + const std::string &Column::getDefaultValue() const { return m_default; } + + void Column::setDefaultValue(const std::string &defaultValue) { m_default = defaultValue; } + + bool Column::hasDefaultValue() const { return !m_default.empty(); } + + const std::vector< Constraint > &Column::getConstraints() const { return m_constraints; } + + void Column::setConstraints(const std::vector< Constraint > &constraints) { m_constraints = constraints; } + + void Column::addConstraint(const Constraint &constraint) { m_constraints.push_back(constraint); } + + void Column::clearConstraints() { m_constraints.clear(); } + + +} // namespace db +} // namespace mumble diff --git a/src/database/Column.h b/src/database/Column.h new file mode 100644 index 000000000..997540658 --- /dev/null +++ b/src/database/Column.h @@ -0,0 +1,48 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_COLUMN_H_ +#define MUMBLE_DATABASE_COLUMN_H_ + +#include "Constraint.h" +#include "DataType.h" + +#include +#include + +namespace mumble { +namespace db { + + class Column { + public: + Column(const std::string &name = {}, const DataType &type = {}, + const std::vector< Constraint > &constraints = {}, const std::string &defaultValue = {}); + + const std::string getName() const; + void setName(const std::string &name); + + const DataType &getType() const; + void setType(const DataType &type); + + const std::string &getDefaultValue() const; + void setDefaultValue(const std::string &defaultValue); + bool hasDefaultValue() const; + + const std::vector< Constraint > &getConstraints() const; + void setConstraints(const std::vector< Constraint > &constraints); + void addConstraint(const Constraint &constraint); + void clearConstraints(); + + protected: + std::string m_name; + DataType m_type; + std::vector< Constraint > m_constraints; + std::string m_default; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_COLUMN_H_ diff --git a/src/database/ConnectionParameter.h b/src/database/ConnectionParameter.h new file mode 100644 index 000000000..ba80b7e60 --- /dev/null +++ b/src/database/ConnectionParameter.h @@ -0,0 +1,26 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_CONNECTIONPARAMETER_H_ +#define MUMBLE_DATABASE_CONNECTIONPARAMETER_H_ + +#include "database/Backend.h" + +#include + +namespace mumble { +namespace db { + + struct ConnectionParameter { + ConnectionParameter() = default; + virtual ~ConnectionParameter() = default; + + virtual Backend applicability() const = 0; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_CONNECTIONPARAMETER_H_ diff --git a/src/database/Constants.h b/src/database/Constants.h new file mode 100644 index 000000000..75c386fd6 --- /dev/null +++ b/src/database/Constants.h @@ -0,0 +1,19 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_CONSTANTS_H_ +#define MUMBLE_DATABASE_CONSTANTS_H_ + +#include + +namespace mumble { +namespace db { + namespace constants { + const std::string DB_SCHEME_VERSION_KEY = "mumble_database_scheme_version"; + } +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_CONSTANTS_H_ diff --git a/src/database/Constraint.cpp b/src/database/Constraint.cpp new file mode 100644 index 000000000..4e27dfffc --- /dev/null +++ b/src/database/Constraint.cpp @@ -0,0 +1,216 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "Constraint.h" +#include "Backend.h" +#include "Column.h" +#include "DataType.h" +#include "Table.h" +#include "UnsupportedOperationException.h" + +#include +#include + +namespace mumble { +namespace db { + + Constraint::Constraint(Constraint::Type type, const std::string &name, const Table *foreignTable, + const Column *foreignColumn) + : m_type(type), m_name(name) { + // clang-format off + assert(m_type != Constraint::ForeignKey || ( + !m_name.empty() + && foreignTable + && foreignColumn + // Assert that the foreign column is the primary key of the foreign table + // (otherwise we can't create a foreign key on it) + && std::find_if( + foreignColumn->getConstraints().begin(), foreignColumn->getConstraints().end(), + [](const Constraint &constraint) { return constraint.getType() == Constraint::PrimaryKey; }) + != foreignColumn->getConstraints().end() + // In theory we should also assert whether foreignColumn is part of foreignTable, but at the time + // this code runs, it is somewhat likely that the column has not yet been added to its table, so + // we can't assert on that. + )); + // clang-format on + + if (m_type == Constraint::ForeignKey) { + m_foreignColumnName = foreignColumn->getName(); + m_foreignTableName = foreignTable->getName(); + } + } + + Constraint::Type Constraint::getType() const { return m_type; } + + void Constraint::setType(Constraint::Type type) { m_type = type; } + + const std::string Constraint::getName() const { return m_name; } + + void Constraint::setName(const std::string &name) { m_name = name; } + + bool Constraint::canInline() const { + // All constraints can be applied inline, but if we have defined a name for this constraint, + // then we can't inline it, as inlined constraints can't have names. + return m_name.empty() || m_type == Constraint::ForeignKey; + } + + bool Constraint::canOutOfLine() const { + // For out-of-line constraints, we require a name + return !m_name.empty(); + } + + bool Constraint::prefersInline() const { + // https://stackoverflow.com/a/54689613 - apparently applying NOT NULL inline, is (can be) more efficient + return m_type == Constraint::NotNull || m_name.empty(); + } + + bool Constraint::canBeDropped(Backend backend) const { + return backend != Backend::SQLite && (!m_name.empty() || m_type == Constraint::NotNull); + } + + std::string Constraint::inlineSQL(Backend backend) const { + assert(m_name.empty()); + + switch (m_type) { + case Constraint::NotNull: + return "NOT NULL"; + case Constraint::Unique: + return "UNIQUE"; + case Constraint::PrimaryKey: { + switch (backend) { + case Backend::MySQL: + // Fallthrough + case Backend::PostgreSQL: + return "PRIMARY KEY"; + case Backend::SQLite: + // In SQLite primary keys do not imply NOT NULL (in most cases) due to historic reasons, so in + // order to make sure our table conforms to the SQL standard (which does dictate that a primary + // key must not be NULL), we have to add the NOT NULL constraint ourselves. + return "PRIMARY KEY NOT NULL"; + } + + // This code should be unreachable + assert(false); + return "PRIMARY KEY"; + } + case Constraint::ForeignKey: + assert(false); + throw UnsupportedOperationException("Foreign keys can't be added inline"); + } + + // This code should never be reached + assert(false); + return ""; + } + + std::string Constraint::outOfLineSQL(const Column &column, Backend backend) const { + assert(!m_name.empty()); + + std::string query = "CONSTRAINT " + m_name + " "; + + switch (m_type) { + case Constraint::NotNull: + // In order to have the NOT NULL constraint as a out-of-line constraint, we'll have to rewrite + // it into a CHECK constraint + query += "CHECK(" + column.getName() + " IS NOT NULL)"; + break; + case Constraint::Unique: + query += "UNIQUE(" + column.getName() + ")"; + break; + case Constraint::PrimaryKey: + query += "PRIMARY KEY (" + column.getName() + ")"; + switch (backend) { + case Backend::MySQL: + // Fallthrough + case Backend::PostgreSQL: + // Do nothing special + break; + case Backend::SQLite: + // Explicitly also add NOT NULL as this is not implied for most cases in SQLite (see inlineSQL + // for more info). + // Note that since SQLite doesn't allow for dropping constraints anyway, we don't have to worry + // about remembering the dummy name. + query += ", CONSTRAINT " + m_name + "_sqlite_special_not_null " + "CHECK(" + column.getName() + + " IS NOT NULL)"; + break; + } + break; + case Constraint::ForeignKey: + assert(!m_foreignTableName.empty()); + assert(!m_foreignColumnName.empty()); + + // We want to make sure that the referencing key is always staying synchronized with the referenced + // (foreign) key. Thus, if the referenced value changes, we want the reference to change as well and if + // the referenced value gets deleted, we also want to delete the row referencing that value. + query += "FOREIGN KEY(" + column.getName() + ") REFERENCES \"" + m_foreignTableName + "\"(" + + m_foreignColumnName + ") ON UPDATE CASCADE ON DELETE CASCADE"; + break; + } + + assert(!query.empty()); + + return query; + } + + std::string Constraint::dropQuery(const Table &table, const Column &column, Backend backend) const { + if (backend == Backend::SQLite) { + throw UnsupportedOperationException("SQLite does not support dropping constraints from tables"); + } else if (backend == Backend::MySQL) { + // MySQL is weird in that it doesn't allow for the standard "DROP CONSTRAINT " syntax, that + // virtually every other RDMS seems to support. Instead, we'll have to use a specific syntax for + // dropping different kinds of constraints. In theory, MySQL 8.0.19 added support for the standard + // DROP CONSTRAINT approach but based on preliminary tests, this support is still not quite + // compatible with other RDMS, so for now we'll stick to using special code for MySQL... + switch (m_type) { + case Constraint::NotNull: + if (m_name.empty()) { + return "ALTER TABLE \"" + table.getName() + "\" MODIFY " + column.getName() + " " + + column.getType().sqlRepresentation() + " NULL"; + } else { + // Named constraints for NOT NULL are implemented as a check + return "ALTER TABLE \"" + table.getName() + "\" DROP CHECK " + m_name; + } + case Constraint::Unique: + assert(!m_name.empty()); + return "ALTER TABLE \"" + table.getName() + "\" DROP INDEX " + m_name; + case Constraint::PrimaryKey: + return "ALTER TABLE \"" + table.getName() + "\" DROP PRIMARY KEY"; + case Constraint::ForeignKey: + assert(!m_name.empty()); + return "ALTER TABLE \"" + table.getName() + "\" DROP FOREIGN KEY " + m_name; + } + } + + if (!m_name.empty()) { + return "ALTER TABLE \"" + table.getName() + "\" DROP CONSTRAINT " + m_name; + } + + switch (m_type) { + case Constraint::NotNull: + if (backend == Backend::PostgreSQL) { + // Postgres is doing this differently than everyone else + return "ALTER TABLE \"" + table.getName() + "\" ALTER COLUMN " + column.getName() + + " DROP NOT NULL"; + } else { + return "ALTER TABLE \"" + table.getName() + "\" MODIFY " + column.getName() + " " + + column.getType().sqlRepresentation() + " NULL"; + } + case Constraint::PrimaryKey: + case Constraint::Unique: + case Constraint::ForeignKey: + // In all of these cases, we'd need a name to drop the constraint. But if we had one, we'd not reach + // this code + throw UnsupportedOperationException( + "The only constraint that can be dropped without a name is NotNull"); + return ""; + } + + // This code should never be reached + assert(false); + return ""; + } + +} // namespace db +} // namespace mumble diff --git a/src/database/Constraint.h b/src/database/Constraint.h new file mode 100644 index 000000000..21ed63565 --- /dev/null +++ b/src/database/Constraint.h @@ -0,0 +1,59 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_CONSTRAINT_H_ +#define MUMBLE_DATABASE_CONSTRAINT_H_ + +#include "Backend.h" + +#include + +namespace mumble { +namespace db { + + class Table; + class Column; + + class Constraint { + public: + enum Type { + NotNull, + Unique, + PrimaryKey, + ForeignKey, + }; + + Constraint(Type type, const std::string &name = {}, const Table *foreignTable = nullptr, + const Column *foreignColumn = nullptr); + ~Constraint() = default; + + Type getType() const; + void setType(Type type); + + const std::string getName() const; + void setName(const std::string &name); + + bool canInline() const; + bool canOutOfLine() const; + bool prefersInline() const; + + bool canBeDropped(Backend backend) const; + + std::string inlineSQL(Backend backend) const; + std::string outOfLineSQL(const Column &column, Backend backend) const; + + std::string dropQuery(const Table &table, const Column &column, Backend backend) const; + + protected: + Type m_type; + std::string m_name; + std::string m_foreignTableName; + std::string m_foreignColumnName; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_CONSTRAINT_H_ diff --git a/src/database/DBUtils.cpp b/src/database/DBUtils.cpp new file mode 100644 index 000000000..43f8be5e8 --- /dev/null +++ b/src/database/DBUtils.cpp @@ -0,0 +1,84 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "DBUtils.h" + +#include + +#include + +#include +#include + +namespace mumble { +namespace db { + namespace utils { + + std::string to_string(const soci::row &row, std::size_t columnIndex) { + const soci::column_properties &props = row.get_properties(columnIndex); + + switch (props.get_data_type()) { + case soci::dt_string: + return row.get< std::string >(columnIndex); + case soci::dt_double: + return std::to_string(row.get< double >(columnIndex)); + case soci::dt_integer: + return std::to_string(row.get< int >(columnIndex)); + case soci::dt_long_long: + return std::to_string(row.get< long long >(columnIndex)); + case soci::dt_unsigned_long_long: + return std::to_string(row.get< unsigned long long >(columnIndex)); + case soci::dt_date: + case soci::dt_xml: + case soci::dt_blob: + std::cerr << "[ERROR]: Tried to convert unsupported DB datatype to string: " + << props.get_data_type() << std::endl; + std::abort(); + } + + assert(false); + return ""; + } + + std::string to_string(const nlohmann::json &json) { + switch (json.type()) { + case nlohmann::json::value_t::string: + // For strings the nlohmann::to_string method would add extra quotes around the actual string, + // which is not what we want. Thus, we have to handle them here explicitly. + return json.get< std::string >(); + default: + return nlohmann::to_string(json); + } + } + + nlohmann::json to_json(const soci::row &row, std::size_t columnIndex) { + const soci::column_properties &props = row.get_properties(columnIndex); + + switch (props.get_data_type()) { + case soci::dt_string: + return row.get< std::string >(columnIndex); + case soci::dt_double: + return row.get< double >(columnIndex); + case soci::dt_integer: + return row.get< int >(columnIndex); + case soci::dt_long_long: + return row.get< long long >(columnIndex); + case soci::dt_unsigned_long_long: + return row.get< unsigned long long >(columnIndex); + case soci::dt_date: + case soci::dt_xml: + case soci::dt_blob: + std::cerr << "[ERROR]: Tried to convert unsupported DB datatype to JSON: " << props.get_data_type() + << std::endl; + std::abort(); + } + + assert(false); + return ""; + } + + } // namespace utils +} // namespace db +} // namespace mumble diff --git a/src/database/DBUtils.h b/src/database/DBUtils.h new file mode 100644 index 000000000..4780937be --- /dev/null +++ b/src/database/DBUtils.h @@ -0,0 +1,30 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_UTILS_H_ +#define MUMBLE_DATABASE_UTILS_H_ + +#include + +#include + +namespace soci { +class row; +} + +namespace mumble { +namespace db { + namespace utils { + + std::string to_string(const soci::row &row, std::size_t columnIndex); + std::string to_string(const nlohmann::json &json); + + nlohmann::json to_json(const soci::row &row, std::size_t columnIndex); + + } // namespace utils +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_UTILS_H_ diff --git a/src/database/DataType.cpp b/src/database/DataType.cpp new file mode 100644 index 000000000..a94fb4df4 --- /dev/null +++ b/src/database/DataType.cpp @@ -0,0 +1,158 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "DataType.h" +#include "Backend.h" + +#include + +#include + +namespace mumble { +namespace db { + + constexpr const std::size_t DataType::Unsized; + + + DataType::DataType(DataType::Type type, std::size_t size) : m_type(type), m_size(size) { + assert((size == Unsized && canBeUnsized()) || (size != Unsized && canBeSized())); + } + + DataType::Type DataType::getType() const { return m_type; } + + void DataType::setType(DataType::Type type) { m_type = type; } + + std::size_t DataType::getSize() const { return m_size; } + + void DataType::setSize(std::size_t size) { m_size = size; } + + bool DataType::isSized() const { return m_size != DataType::Unsized; } + + bool DataType::canBeSized() const { return canBeSized(m_type); } + + bool DataType::canBeSized(DataType::Type type) { + switch (type) { + case DataType::Integer: + return false; + case DataType::SmallInteger: + return false; + case DataType::Double: + return false; + case DataType::FixedSizeString: + return true; + case DataType::String: + return true; + } + + // This code should be unreachable + assert(false); + return false; + } + + bool DataType::canBeUnsized() const { return canBeUnsized(m_type); } + + bool DataType::canBeUnsized(DataType::Type type) { + switch (type) { + case DataType::Integer: + return true; + case DataType::SmallInteger: + return true; + case DataType::Double: + return true; + case DataType::FixedSizeString: + return false; + case DataType::String: + return false; + } + + // This code should be unreachable + assert(false); + return false; + } + + std::string DataType::sqlRepresentation() const { + std::string sqlRepr; + + switch (m_type) { + case DataType::Integer: + sqlRepr = "INTEGER"; + break; + case DataType::SmallInteger: + sqlRepr = "SMALLINT"; + break; + case DataType::Double: + sqlRepr = "DOUBLE PRECISION"; + break; + case DataType::FixedSizeString: + sqlRepr = "CHAR"; + break; + case DataType::String: + sqlRepr = "VARCHAR"; + break; + } + + assert(!sqlRepr.empty()); + + if (isSized()) { + sqlRepr += "(" + std::to_string(m_size) + ")"; + } + + return sqlRepr; + } + + DataType DataType::fromSQLRepresentation(const std::string &strRepr) { + std::string name; + + std::size_t i = 0; + for (; i < strRepr.size(); ++i) { + if (strRepr[i] == '(') { + break; + } + + name += strRepr[i]; + } + + std::size_t size = DataType::Unsized; + if (strRepr[i] == '(') { + if (strRepr[strRepr.size() - 1] != ')' || strRepr.size() - i < 3) { + // If we found a '(', we expect at least something in the form of "(1)" + throw UnknownDataTypeException("Malformed data type size in \"" + strRepr + "\""); + } + + try { + size = std::stoi(strRepr.substr(i + 1, strRepr.size() - 1)); + } catch (const std::invalid_argument &e) { + throw UnknownDataTypeException("Size of data type \"" + strRepr + + "\" could not be parsed: " + e.what()); + } + } + + DataType::Type type; + if (boost::iequals(name, "INTEGER")) { + type = DataType::Integer; + } else if (boost::iequals(name, "SMALLINT")) { + type = DataType::SmallInteger; + } else if (boost::iequals(name, "DOUBLE PRECISION")) { + type = DataType::Double; + } else if (boost::iequals(name, "CHAR")) { + type = DataType::FixedSizeString; + } else if (boost::iequals(name, "VARCHAR")) { + type = DataType::String; + } else { + throw UnknownDataTypeException("Unknown data type \"" + name + "\""); + } + + if (!canBeSized(type) && size != DataType::Unsized) { + throw UnknownDataTypeException("Data type \"" + name + "\" can't be sized"); + } + if (!canBeUnsized(type) && size == DataType::Unsized) { + throw UnknownDataTypeException("Data type \"" + name + "\" has to be sized"); + } + + return DataType(type, size); + } + +} // namespace db +} // namespace mumble diff --git a/src/database/DataType.h b/src/database/DataType.h new file mode 100644 index 000000000..045c8343b --- /dev/null +++ b/src/database/DataType.h @@ -0,0 +1,59 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_DATATYPE_H_ +#define MUMBLE_DATABASE_DATATYPE_H_ + +#include "Exception.h" + +#include +#include + +namespace mumble { +namespace db { + + struct UnknownDataTypeException : public Exception { + using Exception::Exception; + }; + + class DataType { + public: + enum Type { + Integer, + SmallInteger, + Double, + FixedSizeString, + String, + }; + static constexpr const std::size_t Unsized = std::numeric_limits< std::size_t >::max(); + + DataType(Type type = {}, std::size_t size = Unsized); + ~DataType() = default; + + Type getType() const; + void setType(Type type); + + std::size_t getSize() const; + void setSize(std::size_t size); + bool isSized() const; + + bool canBeSized() const; + bool canBeUnsized() const; + + static bool canBeSized(Type type); + static bool canBeUnsized(Type type); + + std::string sqlRepresentation() const; + static DataType fromSQLRepresentation(const std::string &strRepr); + + protected: + Type m_type; + std::size_t m_size; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_DATATYPE_H_ diff --git a/src/database/Database.cpp b/src/database/Database.cpp new file mode 100644 index 000000000..568368dd3 --- /dev/null +++ b/src/database/Database.cpp @@ -0,0 +1,525 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "Database.h" +#include "AccessException.h" +#include "Constants.h" +#include "FormatException.h" +#include "InitException.h" +#include "MetaTable.h" +#include "MySQLConnectionParameter.h" +#include "PostgreSQLConnectionParameter.h" +#include "SQLiteConnectionParameter.h" + +#include +#include +#include +#include + +#include + +#include + +// These functions are defined by the respective SOCI backend libraries +extern "C" void register_factory_sqlite3(); +extern "C" void register_factory_mysql(); +extern "C" void register_factory_postgresql(); + +namespace mumble { +namespace db { + + struct find_by_name { + const std::string &name; + + bool operator()(const std::unique_ptr< Table > &ptr) { return ptr && ptr->getName() == name; } + }; + + Database::Database(Backend backend) : m_backend(backend){}; + + Database::~Database() {} + + void Database::init(const ConnectionParameter ¶meter) { + assert(parameter.applicability() == m_backend); + if (parameter.applicability() != m_backend) { + throw InitException("Supplied connection parameter does not apply to chosen database backend"); + } + + connectToDB(parameter); + + applyBackendSpecificSetup(parameter); + + // Create a meta-table + auto metaTable = std::make_unique< MetaTable >(m_sql, m_backend); + + if (!tableExistsInDB(metaTable->getName())) { + metaTable->create(); + } + + unsigned int schemeVersion = metaTable->getSchemeVersion(); + + addTable(std::move(metaTable)); + + // Make sure all standard tables are added + setupStandardTables(); + + if (schemeVersion == 0) { + // The scheme version entry does not exist. We assume that this means that this is a freshly created DB + createTables(); + } else if (schemeVersion < getSchemeVersion()) { + // The DB is still using an older scheme than we want to use -> perform an upgrade + migrateTables(schemeVersion, getSchemeVersion()); + } else if (schemeVersion > getSchemeVersion()) { + // The DB is using a more recent scheme than we want to use -> abort immediately as we don't know how this + // could possibly look like and we don't want to risk data loss. + throw InitException(std::string("The existing database is using a more recent scheme version (") + + std::to_string(schemeVersion) + ") than the current Mumble instance intends to (" + + std::to_string(getSchemeVersion()) + ")"); + } else { + // The DB's scheme version matches the one we want to use already -> just use as is + } + } + + Database::table_id Database::addTable(std::unique_ptr< Table > table) { + m_tables.push_back(std::move(table)); + + // The "table ID" is just the index of the table in the used vector + return m_tables.size() - 1; + } + + const Table *Database::getTable(const std::string &name) const { + auto it = std::find_if(m_tables.begin(), m_tables.end(), find_by_name{ name }); + + if (it != m_tables.end()) { + return it->get(); + } else { + return nullptr; + } + } + + const Table *Database::getTable(Database::table_id tableID) const { + assert(tableID < m_tables.size()); + + if (tableID < m_tables.size()) { + return m_tables[tableID].get(); + } else { + return nullptr; + } + } + + Table *Database::getTable(const std::string &name) { + // Reuse const implementation + return const_cast< Table * >(const_cast< const Database * >(this)->getTable(name)); + } + + Table *Database::getTable(Database::table_id tableID) { + // Reuse const implementation + return const_cast< Table * >(const_cast< const Database * >(this)->getTable(tableID)); + } + + std::unique_ptr< Table > Database::takeTable(const std::string &name) { + auto it = std::find_if(m_tables.begin(), m_tables.end(), find_by_name{ name }); + + if (it != m_tables.end()) { + // In order to not invalidate an previously assigned table IDs, we can't actually remove the table from our + // vector (that would mess up all IDs (indices) of the tables after it. Instead, we move the pointer out of + // the vector, but keep a nullptr at its original place in the list. + std::unique_ptr< Table > ptr = std::move(*it); + + it->reset(); + + return ptr; + } else { + return nullptr; + } + } + + std::unique_ptr< Table > Database::takeTable(Database::table_id tableID) { + assert(tableID < m_tables.size()); + + if (tableID < m_tables.size()) { + // Same argument here, as in the overload taking a string (see above) + std::unique_ptr< Table > ptr = std::move(m_tables[tableID]); + + m_tables[tableID].reset(); + + return ptr; + } else { + return nullptr; + } + } + + void Database::removeTable(const std::string &name) { takeTable(name); } + + void Database::removeTable(Database::table_id tableID) { takeTable(tableID); } + + void Database::importFromJSON(const nlohmann::json &json, bool createMissingTables) { + // We wrap the entire import into a transaction to make sure, that we don't end up with partial imports if an + // error is encountered during the import. + soci::transaction transaction(m_sql); + + if (!json.contains("meta_data")) { + throw FormatException("JSON-import: JSON is missing top-level \"meta_data\" object"); + } + + importMetaData(json["meta_data"]); + + if (!json.contains("tables")) { + throw FormatException("JSON-import: JSON is missing top-level \"tables\" object"); + } + + const nlohmann::json tables = json["tables"]; + + if (!tables.is_object()) { + throw FormatException("JSON-import: Top-level \"tables\" entry is not of type object"); + } + + for (auto it = tables.begin(); it != tables.end(); ++it) { + std::string tableName = it.key(); + + auto tableIt = std::find_if(m_tables.begin(), m_tables.end(), find_by_name{ tableName }); + + Table *table; + bool tableIsNew = false; + if (tableIt == m_tables.end()) { + if (createMissingTables) { + // Create table on-the-fly + table_id id = addTable(std::make_unique< Table >(m_sql, m_backend, tableName)); + + table = m_tables[id].get(); + tableIsNew = true; + } else { + throw FormatException("JSON-import: Unknown table \"" + tableName + "\""); + } + } else { + table = tableIt->get(); + } + + const nlohmann::json &body = it.value(); + + if (!body.is_object()) { + throw FormatException(std::string("JSON-import: Specification for table \"") + tableName + + "\" is not an object"); + } + + table->importFromJSON(body, tableIsNew); + } + + transaction.commit(); + } + + nlohmann::json Database::exportToJSON() const { + nlohmann::json json; + json["meta_data"] = exportMetaData(); + + nlohmann::json &tables = json["tables"]; + + for (const std::unique_ptr< Table > ¤tTable : m_tables) { + tables[currentTable->getName()] = currentTable->exportToJSON(); + } + + return json; + } + + void Database::destroyTables() { + for (const std::unique_ptr< Table > ¤tTable : m_tables) { + if (currentTable) { + if (tableExistsInDB(currentTable->getName())) { + currentTable->destroy(); + } + } + } + } + + void Database::clearTables() { + for (const std::unique_ptr< Table > ¤tTable : m_tables) { + if (tableExistsInDB(currentTable->getName())) { + currentTable->clear(); + } + } + } + + Version Database::getBackendVersion() { + Version version; + try { + switch (m_backend) { + case Backend::SQLite: { + std::string versionString; + m_sql << "SELECT sqlite_version()", soci::into(versionString); + + if (std::sscanf(versionString.c_str(), "%u.%u.%u", &version.m_major, &version.m_minor, + &version.m_patch) + != 3) { + // Parsing failed + return {}; + } + + break; + } + case Backend::MySQL: { + std::string versionString; + m_sql << "SELECT VERSION()", soci::into(versionString); + + if (std::sscanf(versionString.c_str(), "%u.%u.%u", &version.m_major, &version.m_minor, + &version.m_patch) + != 3) { + // Parsing failed + return {}; + } + + break; + } + case Backend::PostgreSQL: { + std::string versionString; + m_sql << "SHOW server_version", soci::into(versionString); + + // Postgres seems to (sometimes) only supply major and minor version, so we're also content with + // only parsing 2 version segments + int parsedFields = std::sscanf(versionString.c_str(), "%u.%u.%u", &version.m_major, + &version.m_minor, &version.m_patch); + if (parsedFields != 2 && parsedFields != 3) { + // Parsing failed + return {}; + } + + break; + } + } + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed to query DB backend version: ") + e.what()); + } + + return version; + } + + void Database::connectToDB(const ConnectionParameter ¶meter) { + assert(parameter.applicability() == m_backend); + + std::string connectionString; + switch (m_backend) { + case Backend::SQLite: { + register_factory_sqlite3(); + const auto &sqliteParameter = static_cast< const SQLiteConnectionParameter & >(parameter); + + connectionString = "sqlite3://dbname=" + sqliteParameter.dbPath; + break; + } + case Backend::MySQL: { + register_factory_mysql(); + const auto &mysqlParameter = static_cast< const MySQLConnectionParameter & >(parameter); + + connectionString = "mysql://dbname='" + mysqlParameter.dbName + "'"; + + if (!mysqlParameter.userName.empty()) { + connectionString += " user='" + mysqlParameter.userName + "'"; + } + if (!mysqlParameter.password.empty()) { + connectionString += " password='" + mysqlParameter.password + "'"; + } + if (!mysqlParameter.host.empty()) { + connectionString += " host='" + mysqlParameter.host + "'"; + } + if (!mysqlParameter.port.empty()) { + connectionString += " port='" + mysqlParameter.port + "'"; + } + break; + } + case Backend::PostgreSQL: { + register_factory_postgresql(); + const auto &postgresqlParameter = static_cast< const PostgreSQLConnectionParameter & >(parameter); + + connectionString = "postgresql://dbname='" + postgresqlParameter.dbName + "'"; + + if (!postgresqlParameter.userName.empty()) { + connectionString += " user='" + postgresqlParameter.userName + "'"; + } + if (!postgresqlParameter.password.empty()) { + connectionString += " password='" + postgresqlParameter.password + "'"; + } + if (!postgresqlParameter.host.empty()) { + connectionString += " host='" + postgresqlParameter.host + "'"; + } + if (!postgresqlParameter.port.empty()) { + connectionString += " port='" + postgresqlParameter.port + "'"; + } + break; + } + } + + try { + m_sql.open(connectionString); + } catch (const soci::soci_error &e) { + throw InitException(e.what()); + } + } + + bool Database::tableExistsInDB(const std::string &name) { + std::string query; + switch (m_backend) { + case Backend::SQLite: + query = "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=:tableName)"; + break; + case Backend::MySQL: + // Make sure to restrict our search to the currently selected database (we always assume that we have a + // specific DB selected) + query = "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=(SELECT DATABASE()) " + "AND table_name=:tableName)"; + break; + case Backend::PostgreSQL: + query = "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name=:tableName AND " + "table_catalog=(SELECT CURRENT_DATABASE()))"; + break; + } + + try { + int exists = 0; + m_sql << query, soci::use(name), soci::into(exists); + + return exists; + } catch (const soci::soci_error &e) { + // Rethrow + throw AccessException(e.what()); + } + } + + void Database::applyBackendSpecificSetup(const ConnectionParameter ¶meter) { + assert(parameter.applicability() == m_backend); + + try { + switch (m_backend) { + case Backend::SQLite: { + // Check the DB encoding to make sure that it is Unicode-compatible + std::string encoding; + m_sql << "PRAGMA ENCODING", soci::into(encoding); + + if (!boost::iequals(encoding, "UTF-8") && !boost::iequals(encoding, "UTF-16") + && !boost::iequals(encoding, "UTF-16le") && !boost::iequals(encoding, "UTF-16be")) { + throw InitException("Database has invalid encoding \"" + encoding + "\""); + } + + // Make sure that foreign key constraints are actually enforced + m_sql << "PRAGMA foreign_keys = ON"; + int enabled; + m_sql << "PRAGMA foreign_keys", soci::into(enabled); + + if (!enabled) { + throw InitException("Failed at enabling foreign key enforcement"); + } + + const SQLiteConnectionParameter sqliteParam = + static_cast< const SQLiteConnectionParameter & >(parameter); + if (sqliteParam.useWAL) { + // Below pragma statement returns the new journal mode (or the old one on failure) + std::string newJournalMode; + m_sql << "PRAGMA journal_mode=WAL", soci::into(newJournalMode); + + if (!boost::iequals(newJournalMode, "WAL")) { + throw InitException("Failed at changing journal mode from \"" + newJournalMode + + "\" to \"WAL\""); + } + } + + break; + } + case Backend::MySQL: { + if (getBackendVersion() < Version{ 8, 0, 16 }) { + // The CHECK constraint that we use for named NOT NULL constrains was introduced only in + // MySQL 8.0.16 + throw InitException("We require at least MySQL v8.0.16 as earlier version don't implement all " + "necessary features"); + } + // Make MySQL as conforming to ANSI standard SQL as possible + m_sql << "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"; + m_sql << "SET sql_mode = 'ANSI'"; + + // Ensure that MySQL uses proper Unicode character encoding (note that "utf8" isn't actual proper + // UTF8, so we have to use the fixed version "utf8mb4" instead + m_sql << "ALTER DATABASE CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; + + // Ensure that log_bin_trust_function_creators is enabled. This is required in order for us to be + // able to e.g. create triggers, without requiring the SUPER privilege for our user account. + int enabled = 0; + m_sql << "SELECT @@GLOBAL.log_bin_trust_function_creators", soci::into(enabled); + + if (!enabled) { + throw InitException("'log_bin_trust_function_creators' is NOT enabled. Mumble can't operate " + "properly without it."); + } + + // Ensure the binlog_format is ROW to make sure, we don't run into issues due to + // log-bin-trust-function-creators being enabled + std::string binlogFormat; + m_sql << "SELECT @@SESSION.binlog_format", soci::into(binlogFormat); + + if (!boost::iequals(binlogFormat, "ROW")) { + throw InitException( + "Mumble requires MySQL's binlog_format to be set to \"ROW\" but it is set to \"" + + binlogFormat + "\""); + } + + break; + } + case Backend::PostgreSQL: { + // Check the DB encoding to make sure that it is Unicode-compatible + std::string encoding; + m_sql << "SHOW SERVER_ENCODING", soci::into(encoding); + + if (!boost::iequals(encoding, "utf8")) { + throw InitException("Invalid (non-UTF-8) database encoding encountered: " + encoding); + } + + break; + } + } + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Applying DB-specific settings failed: ") + e.what()); + } + } + + void Database::setupStandardTables() { + // The default implementation does nothing + } + + void Database::createTables() { + for (std::unique_ptr< Table > ¤tTable : m_tables) { + if (currentTable->getName() == MetaTable::NAME) { + // Meta table is special. We assume it is created separately + continue; + } + + currentTable->create(); + } + } + + void Database::migrateTables(unsigned int fromSchemeVersion, unsigned int toSchemeVersion) { + for (std::unique_ptr< Table > ¤tTable : m_tables) { + currentTable->migrate(fromSchemeVersion, toSchemeVersion); + } + } + + void Database::importMetaData(const nlohmann::json &metaData) { + if (!metaData.contains("scheme_version")) { + throw FormatException("JSON-import: meta_data is missing \"scheme_version\" field"); + } + if (!metaData["scheme_version"].is_number_integer()) { + throw FormatException("JSON-import: Expected to be \"scheme_version\" field to be of type integer"); + } + if (metaData["scheme_version"].get< unsigned int >() != getSchemeVersion()) { + // For the time being, we will only verify that the specified version scheme will match our current scheme + // version + throw FormatException(std::string("JSON-import: Can't import data for version scheme ") + + std::to_string(metaData["scheme_version"].get< unsigned int >()) + + " when current scheme version is " + std::to_string(getSchemeVersion())); + } + } + + nlohmann::json Database::exportMetaData() const { + nlohmann::json metaData; + metaData["scheme_version"] = getSchemeVersion(); + + return metaData; + } + +} // namespace db +} // namespace mumble diff --git a/src/database/Database.h b/src/database/Database.h new file mode 100644 index 000000000..3ee464a33 --- /dev/null +++ b/src/database/Database.h @@ -0,0 +1,95 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_DATABASE_H_ +#define MUMBLE_DATABASE_DATABASE_H_ + +#include "Backend.h" +#include "ConnectionParameter.h" +#include "NonCopyable.h" +#include "Table.h" +#include "Version.h" + +#include +#include + +#include + +#include + +namespace mumble { +namespace db { + + /** + * A general class representing a database which in turn consists of tables. This is an abstract class + * that is intended to be subclassed for actual implementations. + */ + class Database : NonCopyable { + public: + using table_id = unsigned int; + + Database(Backend backend); + virtual ~Database(); + + virtual void init(const ConnectionParameter ¶meter); + + virtual unsigned int getSchemeVersion() const = 0; + + table_id addTable(std::unique_ptr< Table > table); + + const Table *getTable(const std::string &name) const; + const Table *getTable(table_id tableID) const; + Table *getTable(const std::string &name); + Table *getTable(table_id tableID); + + std::unique_ptr< Table > takeTable(const std::string &name); + std::unique_ptr< Table > takeTable(table_id tableID); + + void removeTable(const std::string &name); + void removeTable(table_id tableID); + + void importFromJSON(const nlohmann::json &json, bool createMissingTables); + nlohmann::json exportToJSON() const; + + /** + * Deletes all tables from the database. Note that this will leave the actual Table objects contained + * in this object in tact. + */ + virtual void destroyTables(); + /** + * Clears all tables contained in this database + */ + virtual void clearTables(); + + /** + * @returns The version of the used DB backend. If the version query fails, an AccessException is thrown. If + * the query succeeds but the result can't be parsed properly, a default constructed Version object (0.0.0) + * is returned. + */ + Version getBackendVersion(); + + protected: + Backend m_backend; + std::vector< std::unique_ptr< Table > > m_tables; + soci::session m_sql; + + void connectToDB(const ConnectionParameter ¶meter); + + bool tableExistsInDB(const std::string &name); + + void applyBackendSpecificSetup(const ConnectionParameter ¶meter); + + virtual void setupStandardTables(); + void createTables(); + void migrateTables(unsigned int fromSchemeVersion, unsigned int toSchemeVersion); + + virtual void importMetaData(const nlohmann::json &json); + virtual nlohmann::json exportMetaData() const; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_DATABASE_H_ diff --git a/src/database/Exception.h b/src/database/Exception.h new file mode 100644 index 000000000..cc44ea991 --- /dev/null +++ b/src/database/Exception.h @@ -0,0 +1,24 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_EXCEPTION_H_ +#define MUMBLE_DATABASE_EXCEPTION_H_ + +#include + +namespace mumble { +namespace db { + + /** + * Exception superclass for all database-related exceptions + */ + struct Exception : public std::runtime_error { + using std::runtime_error::runtime_error; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_EXCEPTION_H_ diff --git a/src/database/FormatException.h b/src/database/FormatException.h new file mode 100644 index 000000000..a33b84074 --- /dev/null +++ b/src/database/FormatException.h @@ -0,0 +1,24 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_FORMATEXCEPTION_H_ +#define MUMBLE_DATABASE_FORMATEXCEPTION_H_ + +#include "Exception.h" + +namespace mumble { +namespace db { + + /** + * Thrown whenever an invalid format is encountered in the context of a database operation + */ + struct FormatException : public Exception { + using Exception::Exception; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_FORMATEXCEPTION_H_ diff --git a/src/database/Index.cpp b/src/database/Index.cpp new file mode 100644 index 000000000..7252ee2e7 --- /dev/null +++ b/src/database/Index.cpp @@ -0,0 +1,64 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "Index.h" +#include "Table.h" + +#include + +namespace mumble { +namespace db { + + Index::Index(const std::string &name, const std::vector< std::string > &columns) + : m_name(name), m_columns(columns) {} + Index::Index(const std::string &name, std::vector< std::string > &&columns) + : m_name(name), m_columns(std::move(columns)) {} + + const std::string &Index::getName() const { return m_name; } + + const std::vector< std::string > &Index::getColumnNames() const { return m_columns; } + + std::string Index::creationQuery(const Table &table, Backend) const { + assert(!m_name.empty()); + assert(!m_columns.empty()); + + std::string query = "CREATE INDEX \"" + m_name + "\" ON \"" + table.getName() + "\" ("; + + for (std::size_t i = 0; i < m_columns.size(); ++i) { + query += m_columns[i]; + + if (i + 1 < m_columns.size()) { + query += ", "; + } + } + + query += ")"; + + return query; + } + + std::string Index::dropQuery(const Table &table, Backend backend) const { + switch (backend) { + case Backend::SQLite: + // Fallthrough + case Backend::PostgreSQL: + return "DROP INDEX \"" + m_name + "\""; + case Backend::MySQL: + return "DROP INDEX \"" + m_name + "\" ON \"" + table.getName() + "\""; + } + + // This code should be unreachable + assert(false); + return ""; + } + + bool operator==(const Index &lhs, const Index &rhs) { + return lhs.m_name == rhs.m_name && lhs.m_columns == rhs.m_columns; + } + + bool operator!=(const Index &lhs, const Index &rhs) { return !(lhs == rhs); } + +} // namespace db +} // namespace mumble diff --git a/src/database/Index.h b/src/database/Index.h new file mode 100644 index 000000000..8b025159e --- /dev/null +++ b/src/database/Index.h @@ -0,0 +1,41 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_INDEX_H_ +#define MUMBLE_DATABASE_INDEX_H_ + +#include "Backend.h" + +#include +#include + +namespace mumble { +namespace db { + + class Table; + + class Index { + public: + Index(const std::string &name, const std::vector< std::string > &columns); + Index(const std::string &name, std::vector< std::string > &&columns); + + const std::string &getName() const; + const std::vector< std::string > &getColumnNames() const; + + std::string creationQuery(const Table &table, Backend backend) const; + std::string dropQuery(const Table &table, Backend backend) const; + + friend bool operator==(const Index &lhs, const Index &rhs); + friend bool operator!=(const Index &lhs, const Index &rhs); + + protected: + std::string m_name; + std::vector< std::string > m_columns; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_INDEX_H_ diff --git a/src/database/InitException.h b/src/database/InitException.h new file mode 100644 index 000000000..0d50b29ac --- /dev/null +++ b/src/database/InitException.h @@ -0,0 +1,24 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_INITEXCEPTION_H_ +#define MUMBLE_DATABASE_INITEXCEPTION_H_ + +#include "Exception.h" + +namespace mumble { +namespace db { + + /** + * Thrown whenever an error occurs during database initialization + */ + struct InitException : public Exception { + using Exception::Exception; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_INITEXCEPTION_H_ diff --git a/src/database/MetaTable.cpp b/src/database/MetaTable.cpp new file mode 100644 index 000000000..42077f6f8 --- /dev/null +++ b/src/database/MetaTable.cpp @@ -0,0 +1,50 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "MetaTable.h" +#include "AccessException.h" +#include "Column.h" +#include "DataType.h" +#include "InitException.h" + +#include + +#include "FormatException.h" +#include + +namespace mumble { +namespace db { + + constexpr const char *MetaTable::NAME; + + MetaTable::MetaTable(soci::session &sql, Backend backend) + : Table(sql, backend, MetaTable::NAME, + { Column("meta_key", DataType(DataType::String, 500), + { Constraint(Constraint::Unique, std::string(MetaTable::NAME) + "_unique_key"), + Constraint(Constraint::NotNull), Constraint(Constraint::PrimaryKey) }), + Column("meta_value", DataType(DataType::String, 5000), { Constraint(Constraint::NotNull) }) }) {} + + unsigned int MetaTable::getSchemeVersion() { + try { + unsigned int version = 0; + m_sql << "SELECT meta_value FROM " << m_name << " WHERE meta_key='version'", soci::into(version); + + return version; + } catch (const soci::soci_error &e) { + throw AccessException(e.what()); + } + } + + void MetaTable::setSchemeVersion(unsigned int version) { + try { + m_sql << "INSERT INTO \"" << m_name << "\" (meta_key, meta_value) VALUES ('scheme_version', :version)", + soci::use(version); + } catch (const soci::soci_error &e) { + throw AccessException(e.what()); + } + } + +} // namespace db +} // namespace mumble diff --git a/src/database/MetaTable.h b/src/database/MetaTable.h new file mode 100644 index 000000000..d1fca54b4 --- /dev/null +++ b/src/database/MetaTable.h @@ -0,0 +1,27 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_METATABLE_H_ +#define MUMBLE_DATABASE_METATABLE_H_ + +#include "Backend.h" +#include "Table.h" + +namespace mumble { +namespace db { + + class MetaTable : public Table { + public: + static constexpr const char *NAME = "meta"; + MetaTable(soci::session &sql, Backend backend); + + unsigned int getSchemeVersion(); + void setSchemeVersion(unsigned int version); + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_METATABLE_H_ diff --git a/src/database/MySQLConnectionParameter.cpp b/src/database/MySQLConnectionParameter.cpp new file mode 100644 index 000000000..e1fda5f00 --- /dev/null +++ b/src/database/MySQLConnectionParameter.cpp @@ -0,0 +1,20 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "database/MySQLConnectionParameter.h" +#include "database/Backend.h" + +#include + +namespace mumble { +namespace db { + + MySQLConnectionParameter::MySQLConnectionParameter(const std::string &dbName) : dbName(dbName) {} + + Backend MySQLConnectionParameter::applicability() const { return Backend::MySQL; } + + +} // namespace db +} // namespace mumble diff --git a/src/database/MySQLConnectionParameter.h b/src/database/MySQLConnectionParameter.h new file mode 100644 index 000000000..5da8e7cf7 --- /dev/null +++ b/src/database/MySQLConnectionParameter.h @@ -0,0 +1,33 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_MYSQLCONNECTIONPARAMETER_H_ +#define MUMBLE_DATABASE_MYSQLCONNECTIONPARAMETER_H_ + +#include "database/Backend.h" +#include "database/ConnectionParameter.h" + +#include + +namespace mumble { +namespace db { + + class MySQLConnectionParameter : public ConnectionParameter { + public: + MySQLConnectionParameter(const std::string &dbName); + + std::string dbName; + std::string userName = ""; + std::string password = ""; + std::string host = ""; + std::string port = ""; + + virtual Backend applicability() const override; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_MYSQLCONNECTIONPARAMETER_H_ diff --git a/src/database/NoDataException.h b/src/database/NoDataException.h new file mode 100644 index 000000000..1289143cc --- /dev/null +++ b/src/database/NoDataException.h @@ -0,0 +1,23 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_NODATAEXCEPTION_H_ +#define MUMBLE_DATABASE_NODATAEXCEPTION_H_ + +#include "AccessException.h" + +namespace mumble { +namespace db { + + /** + * Thrown whenever a query does not result in any (usable) data + */ + struct NoDataException : public AccessException { + using AccessException::AccessException; + }; +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_NODATAEXCEPTION_H_ diff --git a/src/database/PostgreSQLConnectionParameter.cpp b/src/database/PostgreSQLConnectionParameter.cpp new file mode 100644 index 000000000..c0968edd9 --- /dev/null +++ b/src/database/PostgreSQLConnectionParameter.cpp @@ -0,0 +1,20 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "database/PostgreSQLConnectionParameter.h" +#include "database/Backend.h" + +#include + +namespace mumble { +namespace db { + + PostgreSQLConnectionParameter::PostgreSQLConnectionParameter(const std::string &dbName) : dbName(dbName) {} + + Backend PostgreSQLConnectionParameter::applicability() const { return Backend::PostgreSQL; } + + +} // namespace db +} // namespace mumble diff --git a/src/database/PostgreSQLConnectionParameter.h b/src/database/PostgreSQLConnectionParameter.h new file mode 100644 index 000000000..03fda61be --- /dev/null +++ b/src/database/PostgreSQLConnectionParameter.h @@ -0,0 +1,34 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_POSTGRESQLCONNECTIONPARAMETER_H_ +#define MUMBLE_DATABASE_POSTGRESQLCONNECTIONPARAMETER_H_ + +#include "database/Backend.h" +#include "database/ConnectionParameter.h" + +#include + +namespace mumble { +namespace db { + + class PostgreSQLConnectionParameter : public ConnectionParameter { + public: + PostgreSQLConnectionParameter(const std::string &dbName); + + std::string dbName; + std::string userName = ""; + std::string password = ""; + // Explicitly use localhost IP to circumvent peer authentication errors + std::string host = "127.0.0.1"; + std::string port = ""; + + virtual Backend applicability() const override; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_POSTGRESQLCONNECTIONPARAMETER_H_ diff --git a/src/database/SQLiteConnectionParameter.cpp b/src/database/SQLiteConnectionParameter.cpp new file mode 100644 index 000000000..1192d6659 --- /dev/null +++ b/src/database/SQLiteConnectionParameter.cpp @@ -0,0 +1,25 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "SQLiteConnectionParameter.h" + +#include + +namespace mumble { +namespace db { + + SQLiteConnectionParameter::SQLiteConnectionParameter(const std::string &dbPath, bool useWAL) : useWAL(useWAL) { + // Make sure the proper file extension is used + if (!boost::iends_with(dbPath, ".sqlite")) { + this->dbPath = dbPath + ".sqlite"; + } else { + this->dbPath = dbPath; + } + } + + Backend SQLiteConnectionParameter::applicability() const { return Backend::SQLite; } + +} // namespace db +} // namespace mumble diff --git a/src/database/SQLiteConnectionParameter.h b/src/database/SQLiteConnectionParameter.h new file mode 100644 index 000000000..ce237a216 --- /dev/null +++ b/src/database/SQLiteConnectionParameter.h @@ -0,0 +1,31 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_SQLITECONNECTIONPARAMTER_H_ +#define MUMBLE_DATABASE_SQLITECONNECTIONPARAMTER_H_ + +#include "database/Backend.h" +#include "database/ConnectionParameter.h" + +#include + +namespace mumble { +namespace db { + + class SQLiteConnectionParameter : public ConnectionParameter { + public: + SQLiteConnectionParameter(const std::string &dbPath, bool useWAL = true); + + std::string dbPath; + /** Whether to use write-ahead logging */ + bool useWAL; + + virtual Backend applicability() const override; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_SQLITECONNECTIONPARAMTER_H_ diff --git a/src/database/TODO b/src/database/TODO new file mode 100644 index 000000000..4cdbb08fd --- /dev/null +++ b/src/database/TODO @@ -0,0 +1 @@ +- Test DB migration (in general) diff --git a/src/database/Table.cpp b/src/database/Table.cpp new file mode 100644 index 000000000..03a147060 --- /dev/null +++ b/src/database/Table.cpp @@ -0,0 +1,418 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "Table.h" +#include "AccessException.h" +#include "DBUtils.h" +#include "FormatException.h" + +#include + +#include + +#include + +#include +#include +#include + +namespace mumble { +namespace db { + + // If this looks weird to you, check out https://stackoverflow.com/a/8016853 + // (Essentially this is needed to avoid an undefined reference error for this constant) + constexpr const char *Table::BACKUP_SUFFIX; + + Table::Table(soci::session &sql, Backend backend) : Table(sql, backend, std::string{}, {}) {} + Table::Table(soci::session &sql, Backend backend, const std::string &name) : Table(sql, backend, name, {}) {} + Table::Table(soci::session &sql, Backend backend, const std::string &name, const std::vector< Column > &columns) + : m_name(name), m_columns(columns), m_sql(sql), m_backend(backend) { + performCtorAssertions(); + } + + const std::string &Table::getName() const { return m_name; } + void Table::setName(const std::string &name) { m_name = name; } + + const std::vector< Column > &Table::getColumns() const { return m_columns; } + + void Table::setColumns(const std::vector< Column > &columns) { + // we don't support overwriting columns after they have been initialized already + assert(m_columns.empty()); + + m_columns = columns; + } + + const Column *Table::findColumn(const std::string &name) const { + for (const Column ¤tCol : m_columns) { + if (currentCol.getName() == name) { + return ¤tCol; + } + } + + return nullptr; + } + + bool Table::containsColumn(const std::string &name) const { return findColumn(name) != nullptr; } + + void Table::create() { + assert(!m_name.empty()); + assert(!m_columns.empty()); + + std::string createQuery = "CREATE TABLE \"" + m_name + "\" ("; + std::string outOfLineConstraints; + + for (const Column ¤tColumn : m_columns) { + createQuery += currentColumn.getName() + " " + currentColumn.getType().sqlRepresentation(); + + if (currentColumn.hasDefaultValue()) { + createQuery += " DEFAULT "; + std::string defaultValue = currentColumn.getDefaultValue(); + if (currentColumn.getType().getType() == DataType::String + || currentColumn.getType().getType() == DataType::FixedSizeString) { + // Escape single quotes by doubling them up + boost::replace_all(defaultValue, "'", "''"); + + // We'll have to wrap the value in quotes in order to make sure it gets recognized as a String + createQuery += "'" + defaultValue + "'"; + } else { + // If the default is not a String-type, we don't want to see spaces in it + assert(defaultValue.find(" ") == std::string::npos); + + createQuery += defaultValue; + } + } + + for (const Constraint ¤tConstraint : currentColumn.getConstraints()) { + if (currentConstraint.canInline() + && (currentConstraint.prefersInline() || !currentConstraint.canOutOfLine())) { + createQuery += " " + currentConstraint.inlineSQL(m_backend); + } else { + assert(currentConstraint.canOutOfLine()); + + outOfLineConstraints += currentConstraint.outOfLineSQL(currentColumn, m_backend) + ", "; + } + } + + createQuery += ", "; + } + + // Append out-of-line constraints + createQuery += outOfLineConstraints; + + // Remove trailing ", " + createQuery.erase(createQuery.size() - 2); + + createQuery += ")"; + + try { + m_sql << createQuery; + + // Also create all necessary indices + for (const Index ¤tIndex : m_indices) { + m_sql << currentIndex.creationQuery(*this, m_backend); + } + } catch (const soci::soci_error &e) { + throw AccessException(e.what()); + } + } + + void Table::migrate(unsigned int fromSchemeVersion, unsigned int toSchemeVersion) { + (void) fromSchemeVersion; + (void) toSchemeVersion; + // The default implementation does nothing + } + + void Table::destroy() { + assert(!m_name.empty()); + + try { + m_sql << "DROP TABLE \"" << m_name + "\""; + } catch (const soci::soci_error &e) { + throw AccessException(e.what()); + } + } + + void Table::clear() { + assert(!m_name.empty()); + + try { + // Note that thanks to the missing WHERE clause, this deletes all rows in this table + m_sql << "DELETE FROM \"" << m_name + "\""; + } catch (const soci::soci_error &e) { + throw AccessException(e.what()); + } + } + + const std::vector< Index > &Table::getIndices() const { return m_indices; } + + void Table::addIndex(const Index &index, bool applyToDB) { + if (applyToDB) { + try { + m_sql << index.creationQuery(*this, m_backend); + } catch (const soci::soci_error &e) { + throw AccessException("Failed at creating index \"" + index.getName() + "\": " + e.what()); + } + } + + m_indices.push_back(index); + } + + bool Table::removeIndex(const Index &index, bool applyToDB) { + auto it = std::find(m_indices.begin(), m_indices.end(), index); + if (it == m_indices.end()) { + return false; + } + + m_indices.erase(it); + + if (applyToDB) { + try { + m_sql << index.dropQuery(*this, m_backend); + } catch (const soci::soci_error &e) { + throw AccessException("Failed at dropping index \"" + index.getName() + "\": " + e.what()); + } + } + + return true; + } + + const std::vector< Trigger > &Table::getTrigger() const { return m_trigger; } + + void Table::addTrigger(const Trigger &trigger, bool applyToDB) { + if (applyToDB) { + try { + m_sql << trigger.creationQuery(*this, m_backend); + } catch (const soci::soci_error &e) { + throw AccessException("Failed at creating trigger \"" + trigger.getName() + "\": " + e.what()); + } + } + + m_trigger.push_back(trigger); + } + + bool Table::removeTrigger(const Trigger &trigger, bool applyToDB) { + auto it = std::find(m_trigger.begin(), m_trigger.end(), trigger); + if (it == m_trigger.end()) { + return false; + } + + m_trigger.erase(it); + + if (applyToDB) { + try { + m_sql << trigger.dropQuery(*this, m_backend); + } catch (const soci::soci_error &e) { + throw AccessException("Failed at dropping trigger \"" + trigger.getName() + "\": " + e.what()); + } + } + + return true; + } + +#define THROW_FORMATERROR(msg) throw FormatException(std::string("JSON-Import (table \"") + m_name + "\"): " + msg) + void Table::importFromJSON(const nlohmann::json &json, bool create) { + assert(!m_name.empty()); + + if (!json.is_object()) { + THROW_FORMATERROR("Expected table to represented as a single JSON object"); + } + // Validate that the expected fields are present and of the expected type + std::vector< std::pair< std::string, nlohmann::json::value_t > > expectedFields = { + { "column_names", nlohmann::json::value_t::array }, + { "column_types", nlohmann::json::value_t::array }, + { "rows", nlohmann::json::value_t::array } + }; + for (const std::pair< std::string, nlohmann::json::value_t > ¤tPair : expectedFields) { + if (!json.contains(currentPair.first)) { + THROW_FORMATERROR("Table specification is missing the \"" + currentPair.first + "\" field"); + } + if (json[currentPair.first].type() != currentPair.second) { + THROW_FORMATERROR("Field \"" + currentPair.first + "\" is of the wrong type"); + } + } + // Validate that there are no extra fields + if (json.size() > expectedFields.size()) { + THROW_FORMATERROR("Table spec is expected to contain only " + std::to_string(expectedFields.size()) + + " but contained " + std::to_string(json.size())); + } + + const nlohmann::json &colNames = json["column_names"]; + const nlohmann::json &colTypes = json["column_types"]; + const nlohmann::json &rows = json["rows"]; + + // Some more validations + if (colNames.size() != colTypes.size()) { + THROW_FORMATERROR("Amount of column names (" + std::to_string(colNames.size()) + + " does not match column types (" + std::to_string(colTypes.size()) + ")"); + } + for (std::size_t i = 0; i < colNames.size(); ++i) { + if (!colNames[i].is_string()) { + THROW_FORMATERROR("Encountered non-string column name specification at position " + + std::to_string(i + 1)); + } + if (!colTypes[i].is_string()) { + THROW_FORMATERROR("Encountered non-string column type specification at position " + + std::to_string(i + 1)); + } + if (boost::contains(colNames[i].get< std::string >(), " ")) { + THROW_FORMATERROR("Invalid column name \"" + colNames[i].get< std::string >() + "\""); + } + try { + // Check if we can convert the given string to a known data type + DataType::fromSQLRepresentation(colTypes[i].get< std::string >()); + } catch (const UnknownDataTypeException &e) { + THROW_FORMATERROR("Unknown column type \"" + colTypes[i].get< std::string >() + "\" for column \"" + + colNames[i].get< std::string >() + "\": " + e.what()); + } + } + for (std::size_t i = 0; i < rows.size(); ++i) { + const nlohmann::json ¤tRow = rows.at(i); + + if (!currentRow.is_array()) { + THROW_FORMATERROR("Row entry " + std::to_string(i + 1) + " is not of type array"); + } + if (currentRow.size() != colNames.size()) { + THROW_FORMATERROR("Row " + std::to_string(i + 1) + " contains " + std::to_string(currentRow.size()) + + " entries, but " + std::to_string(colNames.size()) + " were expected"); + } + } + + if (!m_columns.empty()) { + // Make sure that the specified columns and types match with our stored specification + if (m_columns.size() != colNames.size()) { + THROW_FORMATERROR("Attempted to import " + std::to_string(colNames.size()) + + " into a pre-defined table that only contains " + std::to_string(m_columns.size()) + + " columns"); + } + for (std::size_t i = 0; i < m_columns.size(); ++i) { + std::string currentName = colNames[i].get< std::string >(); + const Column *col = findColumn(currentName); + + if (!col) { + THROW_FORMATERROR("A column with the name \"" + currentName + + "\" is not part of the pre-defined columns for this table"); + } + if (col->getType().sqlRepresentation() != colTypes[i].get< std::string >()) { + THROW_FORMATERROR("Column type mismatch for column \"" + currentName + "\": Expected: \"" + + col->getType().sqlRepresentation() + "\", got \"" + + colTypes[i].get< std::string >() + "\""); + } + } + } else { + // Import columns as specified + m_columns.resize(colNames.size()); + for (std::size_t i = 0; i < colNames.size(); ++i) { + Column column(colNames[i].get< std::string >(), + DataType::fromSQLRepresentation(colTypes[i].get< std::string >())); + + m_columns[i] = std::move(column); + } + } + + if (create) { + // Now we have all information together that we need in order to create the table + this->create(); + } + + // From this point on we are assuming that the table represented by this object actually exists in the + // respective database, so we can now start inserting the provided data into it. + std::string query = "INSERT INTO \"" + m_name + "\" ("; + std::string valuePlaceholder = ""; + for (std::size_t i = 0; i < colNames.size(); ++i) { + query += colNames[i].get< std::string >(); + valuePlaceholder += ":" + colNames[i].get< std::string >(); + + if (i + 1 < colNames.size()) { + query += ", "; + valuePlaceholder += ", "; + } + } + query += ") VALUES(" + valuePlaceholder + ")"; + + // We assume that this function will be called from a place that already started a transaction + // on the DB, so we can't initiate another one here. + soci::statement stmt = m_sql.prepare << query; + + std::vector< std::string > values; + values.reserve(colNames.size()); + for (const nlohmann::json ¤tRow : rows) { + assert(currentRow.size() == colNames.size()); + + // We have to first transfer our values into the values vector in order to guarantee that they + // are not destroyed in the middle of the DB statement (which might happen, if we were to use + // the temporaries directly) + for (const nlohmann::json ¤tVal : currentRow) { + values.push_back(utils::to_string(currentVal)); + } + for (std::size_t i = 0; i < values.size(); ++i) { + stmt.exchange(soci::use(values[i])); + } + + stmt.define_and_bind(); + stmt.execute(true); + stmt.bind_clean_up(); + + values.clear(); + } + } +#undef THROW_FORMATERROR + + nlohmann::json Table::exportToJSON() { + assert(!m_columns.empty()); + assert(!m_name.empty()); + + nlohmann::json json; + + std::string query = "SELECT "; + for (const Column ¤tColumn : m_columns) { + json["column_names"].push_back(currentColumn.getName()); + json["column_types"].push_back(currentColumn.getType().sqlRepresentation()); + + query += currentColumn.getName() + ", "; + } + // Remove trailing ", " + query.erase(query.size() - 2); + + query += " FROM \"" + m_name + "\""; + + nlohmann::json rows = nlohmann::json::array_t(); + + try { + soci::rowset< soci::row > rowSet = m_sql.prepare << query; + + for (auto it = rowSet.begin(); it != rowSet.end(); ++it) { + const soci::row ¤tRow = *it; + + nlohmann::json jsonRow = nlohmann::json::array_t(); + for (std::size_t i = 0; i < currentRow.size(); ++i) { + jsonRow.push_back(utils::to_json(currentRow, i)); + } + + rows.push_back(std::move(jsonRow)); + } + + json["rows"] = std::move(rows); + } catch (const soci::soci_error &e) { + throw AccessException(e.what()); + } + + return json; + } + + void Table::performCtorAssertions() { + // Names with spaces are not allowed as these cause issues + assert(!boost::contains(m_name, " ")); +#ifndef NDEBUG + for (const Column ¤tColumn : m_columns) { + assert(!boost::contains(currentColumn.getName(), " ")); + } +#endif + + // We reserve the name for a table's backup (needed during migrations) right from the start + assert(!boost::ends_with(m_name, Table::BACKUP_SUFFIX)); + } + +} // namespace db +} // namespace mumble diff --git a/src/database/Table.h b/src/database/Table.h new file mode 100644 index 000000000..ef19a01a6 --- /dev/null +++ b/src/database/Table.h @@ -0,0 +1,82 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_TABLE_H_ +#define MUMBLE_DATABASE_TABLE_H_ + +#include "Backend.h" +#include "Column.h" +#include "Index.h" +#include "Trigger.h" + +#include +#include + +#include + +namespace soci { +class session; +}; + +namespace mumble { +namespace db { + + class Table { + public: + static constexpr const char *BACKUP_SUFFIX = "_backup"; + + Table(soci::session &sql, Backend backend); + Table(soci::session &sql, Backend backend, const std::string &name); + Table(soci::session &sql, Backend backend, const std::string &name, const std::vector< Column > &columns); + virtual ~Table() = default; + + const std::string &getName() const; + void setName(const std::string &name); + + const std::vector< Column > &getColumns() const; + void setColumns(const std::vector< Column > &columns); + const Column *findColumn(const std::string &name) const; + bool containsColumn(const std::string &name) const; + + virtual void create(); + virtual void migrate(unsigned int fromSchemeVersion, unsigned int toSchemeVersion); + virtual void destroy(); + virtual void clear(); + + const std::vector< Index > &getIndices() const; + void addIndex(const Index &index, bool applyToDB); + bool removeIndex(const Index &index, bool applyToDB); + + const std::vector< Trigger > &getTrigger() const; + void addTrigger(const Trigger &trigger, bool applyToDB); + bool removeTrigger(const Trigger &trigger, bool applyToDB); + + /** + * Imports the data from the given JSON into the table represented by this object. Note + * that the caller of this function is expected to already have initiated a database + * transaction to prevent a partial import. + * + * @param json The data to import + * @param create Whether this function shall create the represented table in the associated + * database before starting to import data. + */ + virtual void importFromJSON(const nlohmann::json &json, bool create = false); + virtual nlohmann::json exportToJSON(); + + protected: + std::string m_name; + std::vector< Column > m_columns; + soci::session &m_sql; + Backend m_backend; + std::vector< Index > m_indices; + std::vector< Trigger > m_trigger; + + void performCtorAssertions(); + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_TABLE_H_ diff --git a/src/database/Trigger.cpp b/src/database/Trigger.cpp new file mode 100644 index 000000000..ecf89a7d2 --- /dev/null +++ b/src/database/Trigger.cpp @@ -0,0 +1,102 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "Trigger.h" +#include "Table.h" + +#include + +namespace mumble { +namespace db { + + Trigger::Trigger(const std::string &name, Trigger::Timing timing, Trigger::Event event, + const std::string &triggerBody) + : m_name(name), m_timing(timing), m_event(event), m_triggerBody(triggerBody) {} + + const std::string &Trigger::getName() const { return m_name; } + + void Trigger::setName(const std::string &name) { m_name = name; } + + Trigger::Timing Trigger::getTiming() const { return m_timing; } + + void Trigger::setTiming(Trigger::Timing timing) { m_timing = timing; } + + Trigger::Event Trigger::getEvent() const { return m_event; } + + void Trigger::setEvent(Trigger::Event event) { m_event = event; } + + const std::string &Trigger::getBody() const { return m_triggerBody; } + + void Trigger::setBody(const std::string &body) { m_triggerBody = body; } + + std::string Trigger::creationQuery(const Table &table, Backend backend) const { + std::string query = "CREATE TRIGGER \"" + m_name + "\""; + switch (m_timing) { + case Trigger::Timing::Before: + query += " BEFORE "; + break; + case Trigger::Timing::After: + query += " AFTER "; + break; + } + + switch (m_event) { + case Trigger::Event::Insert: + query += " INSERT "; + break; + case Trigger::Event::Update: + query += " UPDATE "; + break; + case Trigger::Event::Delete: + query += " DELETE "; + break; + } + + query += "ON \"" + table.getName() + "\" FOR EACH ROW "; + switch (backend) { + case Backend::SQLite: + // Fallthrough + case Backend::MySQL: + query += "BEGIN " + m_triggerBody + " END"; + break; + case Backend::PostgreSQL: + // Postgres requires us to create a function that can then be executed by the trigger + query = "CREATE FUNCTION \"" + m_name + "_trigger_function\"" + + "() RETURNS TRIGGER LANGUAGE PLPGSQL AS $$ BEGIN " + m_triggerBody + " END; $$; " + query + + "EXECUTE PROCEDURE \"" + m_name + "_trigger_function\"()"; + break; + } + + return query; + } + + std::string Trigger::dropQuery(const Table &table, Backend backend) const { + switch (backend) { + case Backend::MySQL: + // Fallthrough + case Backend::SQLite: + return "DROP TRIGGER \"" + m_name + "\""; + case Backend::PostgreSQL: + std::string query = "DROP TRIGGER \"" + m_name + "\" ON \"" + table.getName() + "\";"; + + // Also drop the function that we created for this trigger + query += " DROP FUNCTION \"" + m_name + "_trigger_function\"()"; + + return query; + } + + // This code should be unreachable + assert(false); + + return "DROP TRIGGER \"" + m_name + "\""; + } + + bool operator==(const Trigger &lhs, const Trigger &rhs) { + return lhs.m_name == rhs.m_name && lhs.m_timing == rhs.m_timing && lhs.m_event == rhs.m_event + && lhs.m_triggerBody == rhs.m_triggerBody; + } + +} // namespace db +} // namespace mumble diff --git a/src/database/Trigger.h b/src/database/Trigger.h new file mode 100644 index 000000000..9b5e1ead7 --- /dev/null +++ b/src/database/Trigger.h @@ -0,0 +1,61 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_TRIGGER_H_ +#define MUMBLE_DATABASE_TRIGGER_H_ + +#include "Backend.h" + +#include + +namespace mumble { +namespace db { + + class Table; + + class Trigger { + public: + enum class Timing { + Before, + After, + }; + enum class Event { + Insert, + Update, + Delete, + }; + + Trigger(const std::string &name = "", Timing timing = Timing::After, Event event = Event::Insert, + const std::string &triggerBody = ""); + + const std::string &getName() const; + void setName(const std::string &name); + + Timing getTiming() const; + void setTiming(Timing timing); + + Event getEvent() const; + void setEvent(Event event); + + const std::string &getBody() const; + void setBody(const std::string &body); + + std::string creationQuery(const Table &table, Backend backend) const; + std::string dropQuery(const Table &table, Backend backend) const; + + friend bool operator==(const Trigger &lhs, const Trigger &rhs); + friend bool operator!=(const Trigger &lhs, const Trigger &rhs); + + protected: + std::string m_name; + Timing m_timing; + Event m_event; + std::string m_triggerBody; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_TRIGGER_H_ diff --git a/src/database/UnsupportedOperationException.h b/src/database/UnsupportedOperationException.h new file mode 100644 index 000000000..31650ce75 --- /dev/null +++ b/src/database/UnsupportedOperationException.h @@ -0,0 +1,24 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_UNSUPPORTEDOPERATIONEXCEPTION_H_ +#define MUMBLE_DATABASE_UNSUPPORTEDOPERATIONEXCEPTION_H_ + +#include "Exception.h" + +namespace mumble { +namespace db { + + /** + * Thrown whenever an operation is not supported (in the current context) + */ + struct UnsupportedOperationException : public Exception { + using Exception::Exception; + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_UNSUPPORTEDOPERATIONEXCEPTION_H_ diff --git a/src/database/Utils.h b/src/database/Utils.h new file mode 100644 index 000000000..be6d55cc3 --- /dev/null +++ b/src/database/Utils.h @@ -0,0 +1,33 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_UTILS_H_ +#define MUMBLE_DATABASE_UTILS_H_ + +#include "NoDataException.h" + +#include + +namespace mumble { +namespace db { + namespace utils { + + /** + * Tests the given SQL session to see if the last query resulted in any data having been fetched. If not, + * this function will throw an exception of the given type. + * + * @tparam Exception The type of the exception that is to be thrown + */ + template< typename Exception = NoDataException > void verifyQueryResultedInData(soci::session &sql) { + if (!sql.got_data()) { + throw Exception("Query did not result in any data having been fetched from the database: " + + sql.get_last_query()); + } + } + } // namespace utils +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_UTILS_H_ diff --git a/src/database/Version.cpp b/src/database/Version.cpp new file mode 100644 index 000000000..5b5c9eb70 --- /dev/null +++ b/src/database/Version.cpp @@ -0,0 +1,42 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "Version.h" + +namespace mumble { +namespace db { + + Version::Version(unsigned int majorVersion, unsigned int minorVersion, unsigned int patchVersion) + : m_major(majorVersion), m_minor(minorVersion), m_patch(patchVersion) {} + + std::string Version::toString() const { + return std::to_string(m_major) + "." + std::to_string(m_minor) + "." + std::to_string(m_patch); + } + + bool operator==(const Version &lhs, const Version &rhs) { + return lhs.m_major == rhs.m_major && lhs.m_minor == rhs.m_minor && lhs.m_patch == rhs.m_patch; + } + + bool operator!=(const Version &lhs, const Version &rhs) { return !(lhs == rhs); } + + bool operator<(const Version &lhs, const Version &rhs) { + if (lhs.m_major != rhs.m_major) { + return lhs.m_major < rhs.m_major; + } + if (lhs.m_minor != rhs.m_minor) { + return lhs.m_minor < rhs.m_minor; + } + + return lhs.m_patch < rhs.m_patch; + } + + bool operator<=(const Version &lhs, const Version &rhs) { return lhs == rhs || lhs < rhs; } + + bool operator>(const Version &lhs, const Version &rhs) { return lhs != rhs && !(lhs < rhs); } + + bool operator>=(const Version &lhs, const Version &rhs) { return lhs == rhs || !(lhs < rhs); } + +} // namespace db +} // namespace mumble diff --git a/src/database/Version.h b/src/database/Version.h new file mode 100644 index 000000000..005b1993b --- /dev/null +++ b/src/database/Version.h @@ -0,0 +1,35 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_DATABASE_VERSION_H_ +#define MUMBLE_DATABASE_VERSION_H_ + +#include + +namespace mumble { +namespace db { + + struct Version { + Version(unsigned int majorVersion = 0, unsigned int minorVersion = 0, unsigned int patchVersion = 0); + ~Version() = default; + + unsigned int m_major = 0; + unsigned int m_minor = 0; + unsigned int m_patch = 0; + + std::string toString() const; + + friend bool operator==(const Version &lhs, const Version &rhs); + friend bool operator!=(const Version &lhs, const Version &rhs); + friend bool operator<(const Version &lhs, const Version &rhs); + friend bool operator<=(const Version &lhs, const Version &rhs); + friend bool operator>(const Version &lhs, const Version &rhs); + friend bool operator>=(const Version &lhs, const Version &rhs); + }; + +} // namespace db +} // namespace mumble + +#endif // MUMBLE_DATABASE_VERSION_H_ diff --git a/src/mumble/CMakeLists.txt b/src/mumble/CMakeLists.txt index 9ca258469..357b0f97c 100644 --- a/src/mumble/CMakeLists.txt +++ b/src/mumble/CMakeLists.txt @@ -499,13 +499,15 @@ else() endif() -if(bundled-json) - set(JSON_BuildTests OFF CACHE INTERNAL "") - set(JSON_ImplicitConversions ON CACHE INTERNAL "") - set(JSON_SystemInclude ON CACHE INTERNAL "") - add_subdirectory("${3RDPARTY_DIR}/nlohmann_json/" "${CMAKE_CURRENT_BINARY_DIR}/nlohmann_json/" EXCLUDE_FROM_ALL) -else() - find_pkg("nlohmann_json" REQUIRED) +if (NOT TARGET nlohmann_json) + if(bundled-json) + set(JSON_BuildTests OFF CACHE INTERNAL "") + set(JSON_ImplicitConversions ON CACHE INTERNAL "") + set(JSON_SystemInclude ON CACHE INTERNAL "") + add_subdirectory("${3RDPARTY_DIR}/nlohmann_json/" "${CMAKE_CURRENT_BINARY_DIR}/nlohmann_json/" EXCLUDE_FROM_ALL) + else() + find_pkg("nlohmann_json" REQUIRED) + endif() endif() target_link_libraries(mumble_client_object_lib PUBLIC nlohmann_json::nlohmann_json) diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 0e7808595..b051b6b02 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -30,6 +30,7 @@ endif() # Shared tests use_test("TestCryptographicHash") use_test("TestCryptographicRandom") +use_test("TestDatabase") use_test("TestFFDHE") use_test("TestPacketDataStream") use_test("TestPasswordGenerator") diff --git a/src/tests/TestDatabase/CMakeLists.txt b/src/tests/TestDatabase/CMakeLists.txt new file mode 100644 index 000000000..492a6c573 --- /dev/null +++ b/src/tests/TestDatabase/CMakeLists.txt @@ -0,0 +1,28 @@ +# Copyright 2022 The Mumble Developers. All rights reserved. +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE file at the root of the +# Mumble source tree or at . + +option(database-mysql-test "Whether to include the MySQL database tests (requires special setup)" OFF) +option(database-postgresql-tests "Whether to include the PostgreSQL database tests (requires special setup)" OFF) + +add_executable(TestDatabase + DatabaseTest.cpp + DefaultTable.cpp + TestUtils.cpp + KeyValueTable.cpp + ConstraintTable.cpp +) + +if(database-mysql-tests) + target_compile_definitions(TestDatabase PRIVATE MUMBLE_TEST_MYSQL) +endif() +if(database-postgresql-tests) + target_compile_definitions(TestDatabase PRIVATE MUMBLE_TEST_POSTGRESQL) +endif() + +set_target_properties(TestDatabase PROPERTIES AUTOMOC ON) + +target_link_libraries(TestDatabase PRIVATE Qt5::Test mumble_database) + +add_test(NAME TestDatabase COMMAND $) diff --git a/src/tests/TestDatabase/ConstraintTable.cpp b/src/tests/TestDatabase/ConstraintTable.cpp new file mode 100644 index 000000000..a56f7a39a --- /dev/null +++ b/src/tests/TestDatabase/ConstraintTable.cpp @@ -0,0 +1,180 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "ConstraintTable.h" +#include "database/AccessException.h" +#include "database/Column.h" +#include "database/Constraint.h" +#include "database/Utils.h" + +#include + +namespace mumble { +namespace db { + namespace test { + + constexpr const char *ConstraintTable::NAME; + + /* + * Note that since we are adding columns with NOT NULL, UNIQUE and PRIMARY KEY constraints, we have to make sure + * that upon every insert that we are performing, we also add explicit values for these columns as otherwise the + * respective constraints might easily get violated (e.g. because NULL is inserted as a default value or the + * same value (NULL/EMPTY) is inserted multiple times. + */ + + ConstraintTable::ConstraintTable(soci::session &sql, Backend backend) : Table(sql, backend, NAME) { + std::vector< Column > columns; + + columns.push_back(Column("not_null_key", DataType(DataType::String, 100), + { Constraint(Constraint::NotNull) }, "myDefaultValue")); + columns.push_back(Column("not_null_value", DataType(DataType::String, 100))); + + columns.push_back( + Column("unique_key", DataType(DataType::String, 100), { Constraint(Constraint::Unique) })); + columns.push_back(Column("unique_value", DataType(DataType::String, 100))); + + columns.push_back( + Column("primary_key_key", DataType(DataType::String, 100), { Constraint(Constraint::PrimaryKey) })); + std::size_t primaryKeyIndex = columns.size() - 1; + columns.push_back(Column("primary_key_value", DataType(DataType::String, 100))); + + columns.push_back(Column( + "foreign_key_key", DataType(DataType::String, 100), + { Constraint(Constraint::ForeignKey, "myForeignKeyConstraint", this, &columns[primaryKeyIndex]) })); + columns.push_back(Column("foreign_key_value", DataType(DataType::String, 100))); + + setColumns(columns); + } + + void ConstraintTable::insertNotNull(const std::string &key, const std::string &value) { + std::string dummy = "dummy" + std::to_string(dummyCounter++); + try { + m_sql << "INSERT INTO \"" + getName() + + "\" (not_null_key, not_null_value, unique_key, primary_key_key) VALUES (:key, :value, " + ":dummy1, :dummy2)", + soci::use(key), soci::use(value), soci::use(dummy), soci::use(dummy); + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at inserting not-null value: ") + e.what()); + } + } + + void ConstraintTable::insertUnique(const std::string &key, const std::string &value) { + std::string dummy = "dummy" + std::to_string(dummyCounter++); + try { + m_sql << "INSERT INTO \"" + getName() + + "\" (unique_key, unique_value, primary_key_key) VALUES (:key, :value, :dummy)", + soci::use(key), soci::use(value), soci::use(dummy); + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at inserting unique value: ") + e.what()); + } + } + + void ConstraintTable::insertPrimaryKey(const std::string &key, const std::string &value) { + std::string dummy = "dummy" + std::to_string(dummyCounter++); + try { + m_sql << "INSERT INTO \"" + getName() + + "\" (primary_key_key, primary_key_value, unique_key) VALUES (:key, :value, :dummy)", + soci::use(key), soci::use(value), soci::use(dummy); + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at inserting primary key value: ") + e.what()); + } + } + + void ConstraintTable::insertForeignKey(const std::string &key, const std::string &value) { + std::string dummy = "dummy" + std::to_string(dummyCounter++); + try { + m_sql << "INSERT INTO \"" + getName() + + "\" (foreign_key_key, foreign_key_value, unique_key, primary_key_key) VALUES (:key, " + ":value, :dummy1, :dummy2)", + soci::use(key), soci::use(value), soci::use(dummy), soci::use(dummy); + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at inserting foreign key value: ") + e.what()); + } + } + + std::string ConstraintTable::selectNotNull(const std::string &key) { + try { + std::string value; + m_sql << "SELECT not_null_value FROM \"" + getName() + "\" WHERE not_null_key = :key", soci::use(key), + soci::into(value); + + ::mumble::db::utils::verifyQueryResultedInData(m_sql); + + return value; + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at fetching not-null value: ") + e.what()); + } + } + + std::string ConstraintTable::selectUnique(const std::string &key) { + try { + std::string value; + m_sql << "SELECT unique_value FROM \"" + getName() + "\" WHERE unique_key = :key", soci::use(key), + soci::into(value); + + ::mumble::db::utils::verifyQueryResultedInData(m_sql); + + return value; + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at fetching unique value: ") + e.what()); + } + } + + std::string ConstraintTable::selectPrimaryKey(const std::string &key) { + try { + std::string value; + m_sql << "SELECT primary_key_value FROM \"" + getName() + "\" WHERE primary_key_key = :key", + soci::use(key), soci::into(value); + + ::mumble::db::utils::verifyQueryResultedInData(m_sql); + + return value; + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at fetching primary key value: ") + e.what()); + } + } + + std::string ConstraintTable::selectForeignKey(const std::string &key) { + try { + std::string value; + m_sql << "SELECT foreign_key_value FROM \"" + getName() + "\" WHERE foreign_key_key = :key", + soci::use(key), soci::into(value); + + ::mumble::db::utils::verifyQueryResultedInData(m_sql); + + return value; + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at fetching foreign key value: ") + e.what()); + } + } + + void ConstraintTable::insertNullInNotNullCol() { + try { + m_sql << "INSERT INTO \"" + getName() + "\" (not_null_key, not_null_value) VALUES (NULL, 'NullValue')"; + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at inserting NULL value in not-NULL col: ") + e.what()); + } + } + + void ConstraintTable::insertNullInPrimaryKeyCol() { + try { + m_sql << "INSERT INTO \"" + getName() + + "\" (primary_key_key, primary_key_value) VALUES (NULL, 'NullValue')"; + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at inserting NULL value in primary key col: ") + e.what()); + } + } + + void ConstraintTable::deletePrimaryKey(const std::string &key) { + try { + m_sql << "DELETE FROM \"" + getName() + "\" WHERE primary_key_key = :key", soci::use(key); + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at deleting primary key: ") + e.what()); + } + } + + } // namespace test +} // namespace db +} // namespace mumble diff --git a/src/tests/TestDatabase/ConstraintTable.h b/src/tests/TestDatabase/ConstraintTable.h new file mode 100644 index 000000000..f99893a18 --- /dev/null +++ b/src/tests/TestDatabase/ConstraintTable.h @@ -0,0 +1,50 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_TEST_TESTDATABASE_CONTRAINTTABLE_H_ +#define MUMBLE_TEST_TESTDATABASE_CONTRAINTTABLE_H_ + +#include "database/Backend.h" +#include "database/Table.h" + +#include + +namespace soci { +class session; +} + +namespace mumble { +namespace db { + namespace test { + + class ConstraintTable : public Table { + public: + static constexpr const char *NAME = "ConstraintTable"; + + ConstraintTable(soci::session &sql, Backend backend); + ~ConstraintTable() = default; + +#define DEFINE_TABLE(name) \ + void insert##name(const std::string &key, const std::string &value); \ + std::string select##name(const std::string &key); + + DEFINE_TABLE(NotNull); + DEFINE_TABLE(Unique); + DEFINE_TABLE(PrimaryKey); + DEFINE_TABLE(ForeignKey); +#undef DEFINE_TABLE + void insertNullInNotNullCol(); + void insertNullInPrimaryKeyCol(); + void deletePrimaryKey(const std::string &key); + + protected: + unsigned int dummyCounter = 0; + }; + + } // namespace test +} // namespace db +} // namespace mumble + +#endif // MUMBLE_TEST_TESTDATABASE_CONTRAINTTABLE_H_ diff --git a/src/tests/TestDatabase/DatabaseTest.cpp b/src/tests/TestDatabase/DatabaseTest.cpp new file mode 100644 index 000000000..b20893386 --- /dev/null +++ b/src/tests/TestDatabase/DatabaseTest.cpp @@ -0,0 +1,646 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include +#include + +#include "database/AccessException.h" +#include "database/ConnectionParameter.h" +#include "database/Database.h" +#include "database/FormatException.h" +#include "database/Index.h" +#include "database/MetaTable.h" +#include "database/Trigger.h" +#include "database/UnsupportedOperationException.h" + +#include "ConstraintTable.h" +#include "DefaultTable.h" +#include "KeyValueTable.h" +#include "TestUtils.h" + +#include + +#include +#include +#include + +using namespace mumble::db; + +std::vector< Backend > backends = { + Backend::SQLite, +#ifdef MUMBLE_TEST_MYSQL + Backend::MySQL, +#endif +#ifdef MUMBLE_TEST_POSTGRESQL + Backend::PostgreSQL, +#endif +}; + + +namespace QTest { +// Provide an overload of toString for JSON objects so that Qt's macros can properly display +// these objects to report failing tests +template<> char *toString(const nlohmann::json &json) { + std::string str = json.dump(2); + char *buffer = new char[str.size() + 1]; + std::strcpy(buffer, str.data()); + + return buffer; +} + +template<> char *toString(const std::string &str) { + char *buffer = new char[str.size() + 1]; + std::strcpy(buffer, str.data()); + + return buffer; +} + +}; // namespace QTest + + +class TestDatabase : public Database { +public: + static constexpr const char *EXAMPLE_TABLE_NAME = "sample_table"; + using Database::Database; + ~TestDatabase() override { + // Clear up everything that we have created in our test case + this->destroyTables(); + } + + unsigned int getSchemeVersion() const override { return 42; } + + void connect(const ConnectionParameter ¶m) { Database::connectToDB(param); } + + bool tableExistsInDB(const std::string &name) { return Database::tableExistsInDB(name); } + + soci::session &getSQLHandle() { return m_sql; } +}; + +class DatabaseTest : public QObject { + Q_OBJECT; +private slots: + void connect(); + void getBackendVersion(); + void tableExistsInDB(); + void simpleExport(); + void simpleImport(); + void defaults(); + void constraints(); + void constraintFunctionality(); + void indices(); + void triggers(); + void dataTypes(); + void keyValueTable(); + void unicode(); +}; + +void DatabaseTest::connect() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + + db.connect(test::utils::getConnectionParamter(currentBackend)); + } +} + +void DatabaseTest::getBackendVersion() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + + db.connect(test::utils::getConnectionParamter(currentBackend)); + + Version defaultVersion = {}; + Version queriedVersion = db.getBackendVersion(); + + qInfo() << "Queried version:" << QString::fromStdString(queriedVersion.toString()); + + QVERIFY(queriedVersion > defaultVersion); + } +} + +void DatabaseTest::tableExistsInDB() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + db.init(test::utils::getConnectionParamter(currentBackend)); + + QVERIFY(!db.tableExistsInDB("IDontExist")); + + // The meta-table is always created in the DB init code + QVERIFY(db.tableExistsInDB(MetaTable::NAME)); + + // Create a new table (with capital letters in its name) and ensure that we properly recognize it as well + std::string newTable = "newTable"; + QVERIFY(!db.tableExistsInDB(newTable)); + Database::table_id id = db.addTable( + std::make_unique< Table >(db.getSQLHandle(), currentBackend, newTable, + std::vector< Column >{ Column("dummyCol", DataType(DataType::Integer)) })); + db.getTable(id)->create(); + QVERIFY(db.tableExistsInDB(newTable)); + } +} + +void DatabaseTest::simpleExport() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + db.init(test::utils::getConnectionParamter(currentBackend)); + + MetaTable *metaTable = static_cast< MetaTable * >(db.getTable(MetaTable::NAME)); + + metaTable->setSchemeVersion(5); + + // clang-format off + nlohmann::json expectedJson = { + { "tables", + { + { MetaTable::NAME, + { + { + "column_names", { "meta_key", "meta_value" } + }, + { + "column_types", { metaTable->findColumn("meta_key")->getType().sqlRepresentation(), + metaTable->findColumn("meta_value")->getType().sqlRepresentation() } + }, + { + "rows", nlohmann::json::array({ { "scheme_version", "5" } }) + } + } + } + } + }, + { "meta_data", + { + { "scheme_version", 42 } + } + } + }; + // clang-format on + + nlohmann::json actualJSON = db.exportToJSON(); + + test::utils::alignColumnOrder(actualJSON, expectedJson); + + QCOMPARE(db.exportToJSON(), expectedJson); + } +} + +void DatabaseTest::simpleImport() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + db.init(test::utils::getConnectionParamter(currentBackend)); + + QVERIFY(!db.tableExistsInDB("test_table")); + + MetaTable *metaTable = static_cast< MetaTable * >(db.getTable(MetaTable::NAME)); + + // clang-format off + nlohmann::json serializedDB = { + { "tables", + { + { MetaTable::NAME, + { + { + "column_names", { "meta_key", "meta_value" } + }, + { + "column_types", { metaTable->findColumn("meta_key")->getType().sqlRepresentation(), + metaTable->findColumn("meta_value")->getType().sqlRepresentation() } + }, + { + "rows", nlohmann::json::array({ { "scheme_version", "12" } }) + } + } + }, + { "test_table", + { + { + "column_names", { "col1", "col2", "col3" } + }, + { + "column_types", { "INTEGER", "DOUBLE PRECISION", "VARCHAR(100)" } + }, + { + "rows", nlohmann::json::array({ + { 1, 42.42, "I am a test" }, + { 2, 0.5, "Other test" } + }) + } + } + } + } + }, { "meta_data", + { + { "scheme_version", 1 } + } + } + }; + // clang-format on + + QVERIFY(db.getSchemeVersion() != serializedDB["meta_data"]["scheme_version"].get< unsigned int >()); + + // The scheme_version specified in meta-data doesn't match, so we expect the import to fail (before creating any + // tables) + QVERIFY(db.getTable("test_table") == nullptr); + QVERIFY_EXCEPTION_THROWN(db.importFromJSON(serializedDB, true), FormatException); + QVERIFY(!db.tableExistsInDB("test_table")); + + serializedDB["meta_data"]["scheme_version"] = db.getSchemeVersion(); + + // Importing into a non-existent table without telling the DB to create the missing tables on-the-fly should + // result in an error + QVERIFY(db.getTable("test_table") == nullptr); + QVERIFY_EXCEPTION_THROWN(db.importFromJSON(serializedDB, false), FormatException); + QVERIFY(!db.tableExistsInDB("test_table")); + + // Now with the version_scheme fixed and the necessary flag set, the import should succeed + db.importFromJSON(serializedDB, true); + QVERIFY(db.tableExistsInDB("test_table")); + QVERIFY(db.getTable("test_table") != nullptr); + + + // Exporting should re-yield the exact same JSON that we fed in to populate the DB in the first place + // (potentially minus column order) + nlohmann::json exported = db.exportToJSON(); + + test::utils::alignColumnOrder(exported, serializedDB); + QCOMPARE(exported, serializedDB); + } +} + +void DatabaseTest::defaults() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + + Database::table_id id = db.addTable(std::make_unique< test::DefaultTable >(db.getSQLHandle(), currentBackend)); + + db.init(test::utils::getConnectionParamter(currentBackend)); + + test::DefaultTable *table = static_cast< test::DefaultTable * >(db.getTable(id)); + + QVERIFY(table != nullptr); + + table->insert("randomKey"); + QCOMPARE(table->select("randomKey"), std::string(test::DefaultTable::DEFAULT)); + } +} + +void DatabaseTest::constraints() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + db.init(test::utils::getConnectionParamter(currentBackend)); + + MetaTable *meta = static_cast< MetaTable * >(db.getTable(MetaTable::NAME)); + const Column *metaKeyCol = meta->findColumn("meta_key"); + QVERIFY(metaKeyCol != nullptr); + + std::vector< Constraint > constraints; + constraints.push_back(Constraint(Constraint::NotNull)); // unnamed + constraints.push_back(Constraint(Constraint::NotNull, "my_not_null_constraint")); // named + constraints.push_back(Constraint(Constraint::Unique)); // unnamed + constraints.push_back(Constraint(Constraint::Unique, "my_unique_constraint")); // named + constraints.push_back(Constraint(Constraint::PrimaryKey)); // unnamed + constraints.push_back(Constraint(Constraint::PrimaryKey, "my_primary_key")); // named + constraints.push_back(Constraint(Constraint::ForeignKey, "my_foreign_key", meta, + metaKeyCol)); // named (unnamed doesn't work for foreign keys) + + for (const Constraint ¤tConstraint : constraints) { + qInfo() << " Current constraint: " << currentConstraint.getType() + << " name:" << currentConstraint.getName().c_str(); + + std::vector< Column > columns; + + Column col("test_col", metaKeyCol->getType(), { currentConstraint }); + columns.push_back(std::move(col)); + + std::string tableName = "dummyTable"; + + Database::table_id id = + db.addTable(std::make_unique< Table >(db.getSQLHandle(), currentBackend, tableName, columns)); + + Table *table = db.getTable(id); + QVERIFY(table != nullptr); + + // We always want to start from a clean slate + QVERIFY(!db.tableExistsInDB(tableName)); + + table->create(); + + QVERIFY(db.tableExistsInDB(tableName)); + + if (currentConstraint.canBeDropped(currentBackend)) { + // Also attempt removing the constraint again + // If this doesn't error (throw an exception), we'll assume that it has worked) + db.getSQLHandle() << table->getColumns()[0].getConstraints()[0].dropQuery( + *table, table->getColumns()[0], currentBackend); + } + + // Remove the table again and immediately destroy it + db.takeTable(id)->destroy(); + } + } +} + +void DatabaseTest::constraintFunctionality() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + + Database::table_id id = + db.addTable(std::make_unique< test::ConstraintTable >(db.getSQLHandle(), currentBackend)); + + db.init(test::utils::getConnectionParamter(currentBackend)); + + test::ConstraintTable *table = static_cast< test::ConstraintTable * >(db.getTable(id)); + QVERIFY(table != nullptr); + +#define TEST_INSERT_SELECT_ROUNDTRIP(variant, key, value) \ + table->insert##variant(key, value); \ + QCOMPARE(table->select##variant(key), std::string(value)); + + // Not-Null + qInfo() << "Testing NOT NULL constraint"; + + TEST_INSERT_SELECT_ROUNDTRIP(NotNull, "bla", "blubb"); + // Empty and NULL are different things + TEST_INSERT_SELECT_ROUNDTRIP(NotNull, "", "blubb"); + + // Inserting NULL should error + QVERIFY_EXCEPTION_THROWN(table->insertNullInNotNullCol(), AccessException); + + + // Unique + qInfo() << "Testing UNIQUE constraint"; + TEST_INSERT_SELECT_ROUNDTRIP(Unique, "keyA", "valueA"); + TEST_INSERT_SELECT_ROUNDTRIP(Unique, "keyB", "valueB"); + TEST_INSERT_SELECT_ROUNDTRIP(Unique, "", "valueEmpty"); + + // Inserting any of the above keys again should error + QVERIFY_EXCEPTION_THROWN(table->insertUnique("keyA", "otherA"), AccessException); + QVERIFY_EXCEPTION_THROWN(table->insertUnique("keyB", "otherB"), AccessException); + QVERIFY_EXCEPTION_THROWN(table->insertUnique("", "otherEmpty"), AccessException); + + + // PrimaryKey + qInfo() << "Testing PRIMARY KEY constraint"; + + TEST_INSERT_SELECT_ROUNDTRIP(PrimaryKey, "keyA", "valueA"); + TEST_INSERT_SELECT_ROUNDTRIP(PrimaryKey, "keyB", "valueB"); + TEST_INSERT_SELECT_ROUNDTRIP(PrimaryKey, "", "valueEmpty"); + TEST_INSERT_SELECT_ROUNDTRIP(PrimaryKey, "primaryA", "valueA"); + TEST_INSERT_SELECT_ROUNDTRIP(PrimaryKey, "primaryB", "valueB"); + + // Inserting any of the above keys again should error + QVERIFY_EXCEPTION_THROWN(table->insertPrimaryKey("keyA", "otherA"), AccessException); + QVERIFY_EXCEPTION_THROWN(table->insertPrimaryKey("keyB", "otherB"), AccessException); + QVERIFY_EXCEPTION_THROWN(table->insertPrimaryKey("", "otherEmpty"), AccessException); + // Inserting NULL should also error + QVERIFY_EXCEPTION_THROWN(table->insertNullInPrimaryKeyCol(), AccessException); + + // Deleting an existing key should be fine + table->deletePrimaryKey("keyA"); + table->deletePrimaryKey("keyB"); + table->deletePrimaryKey(""); + + // Fetching one of those keys again should error (because that's what we coded up in ConstraintTable + // in order to easily verify that above deletes were successful) + QVERIFY_EXCEPTION_THROWN(table->selectPrimaryKey("keyA"), AccessException); + QVERIFY_EXCEPTION_THROWN(table->selectPrimaryKey("keyB"), AccessException); + QVERIFY_EXCEPTION_THROWN(table->selectPrimaryKey(""), AccessException); + + + // ForeignKey + qInfo() << "Testing FOREIGN KEY constraint"; + + // Using any key other than the ones that have been added as primary key above should fail. + // And using one of the deleted key from above should also fail + QVERIFY_EXCEPTION_THROWN(table->insertForeignKey("RandomKey", "RandomValue"), AccessException); + QVERIFY_EXCEPTION_THROWN(table->insertForeignKey("keyA", "RandomValue"), AccessException); + QVERIFY_EXCEPTION_THROWN(table->insertForeignKey("", "RandomValue"), AccessException); + + // Inserting using one of the remaining primary keys should succeed + TEST_INSERT_SELECT_ROUNDTRIP(ForeignKey, "primaryA", "Some value"); + TEST_INSERT_SELECT_ROUNDTRIP(ForeignKey, "primaryB", "Other value"); + + // Duplicates should be fine as well (though we currently have no way of fetching all matches) + table->insertForeignKey("primaryB", "Duplicate value"); + + // Deleting a primary key should also delete all rows referencing that key (as foreign key) + table->deletePrimaryKey("primaryB"); + QVERIFY_EXCEPTION_THROWN(table->selectForeignKey("primaryB"), AccessException); + + // The other entry should still be there, though + QCOMPARE(table->selectForeignKey("primaryA"), std::string("Some value")); + +#undef TEST_INSERT_SELECT_ROUNDTRIP + } +} + +void DatabaseTest::indices() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + + Database::table_id id = db.addTable( + std::make_unique< Table >(db.getSQLHandle(), currentBackend, "dummyTable", + std::vector< Column >{ Column("colA", DataType(DataType::SmallInteger)), + Column("colB", DataType(DataType::Double)) })); + + db.init(test::utils::getConnectionParamter(currentBackend)); + + Table *table = db.getTable(id); + const Column *colA = table->findColumn("colA"); + const Column *colB = table->findColumn("colB"); + QVERIFY(colA != nullptr); + QVERIFY(colB != nullptr); + + // We assume that if the addition of the index doesn't throw, it has been added successfully + + Index singleIndex("test_index", { colA->getName() }); + table->addIndex(singleIndex, true); + + Index multiIndex("other_index", { colA->getName(), colB->getName() }); + table->addIndex(multiIndex, true); + + // Adding the same index again should error + QVERIFY_EXCEPTION_THROWN(table->addIndex(singleIndex, true), AccessException); + QVERIFY_EXCEPTION_THROWN(table->addIndex(multiIndex, true), AccessException); + + // The failed additions shouldn't have changed anything + QCOMPARE(table->getIndices().size(), static_cast< std::size_t >(2)); + + // Now the removal should work just fine + QVERIFY(table->removeIndex(singleIndex, true)); + QVERIFY(table->removeIndex(multiIndex, true)); + + // A second removal should fail (the error should be caught before accessing the DB though - thus no exception + // should be thrown) + QVERIFY(!table->removeIndex(singleIndex, true)); + QVERIFY(!table->removeIndex(multiIndex, true)); + + // Re-adding without applying and the removing with applying should now error with an exception + table->addIndex(singleIndex, false); + QVERIFY_EXCEPTION_THROWN(table->removeIndex(singleIndex, true), AccessException); + } +} + +void DatabaseTest::triggers() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + + Database::table_id id = db.addTable( + std::make_unique< Table >(db.getSQLHandle(), currentBackend, "dummyTable", + std::vector< Column >{ Column("colA", DataType(DataType::SmallInteger)), + Column("colB", DataType(DataType::Double)) })); + + db.init(test::utils::getConnectionParamter(currentBackend)); + + Table *table = db.getTable(id); + const Column *colA = table->findColumn("colA"); + QVERIFY(colA != nullptr); + + for (Trigger::Timing timing : { Trigger::Timing::Before, Trigger::Timing::After }) { + qInfo() << "Current timing:" << static_cast< int >(timing); + for (Trigger::Event event : { Trigger::Event::Insert, Trigger::Event::Update, Trigger::Event::Delete }) { + qInfo() << "Current event:" << static_cast< int >(event); + + Trigger trigger("myTestTrigger", timing, event, + "UPDATE \"" + table->getName() + "\" SET " + colA->getName() + "=1;"); + + table->addTrigger(trigger, true); + + // Adding again should error + QVERIFY_EXCEPTION_THROWN(table->addTrigger(trigger, true), AccessException); + + QVERIFY(table->removeTrigger(trigger, true)); + + // Removing again should fail (without error though) + QVERIFY(!table->removeTrigger(trigger, true)); + + // Adding without applying and then removing with applying should error + table->addTrigger(trigger, false); + QVERIFY_EXCEPTION_THROWN(table->removeTrigger(trigger, true), AccessException); + } + } + } +} + +void DatabaseTest::dataTypes() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + + db.init(test::utils::getConnectionParamter(currentBackend)); + + for (DataType::Type currentType : { DataType::Integer, DataType::SmallInteger, DataType::Double, + DataType::FixedSizeString, DataType::String }) { + qInfo() << "Current data type:" << currentType; + + for (bool sized : { true, false }) { + if ((!DataType::canBeSized(currentType) && sized) || (!DataType::canBeUnsized(currentType) && !sized)) { + continue; + } + + qInfo() << "Sized:" << sized; + + DataType type(currentType, sized ? 42 : DataType::Unsized); + + Column col1("dummyCol1", type); + Column col2("dummyCol2", type); + + Database::table_id id = db.addTable(std::make_unique< Table >( + db.getSQLHandle(), currentBackend, "dummyTable", std::vector< Column >{ col1, col2 })); + + Table *table = db.getTable(id); + QVERIFY(table != nullptr); + + // If we can create the table without errors, that means that the used datatypes seem to be fine + table->create(); + + table->destroy(); + + db.takeTable(id); + } + } + } +} + +void DatabaseTest::keyValueTable() { + // This test ensures that our KeyValueTable helper class actually works as expected + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + + Database::table_id id = db.addTable(std::make_unique< test::KeyValueTable >(db.getSQLHandle(), currentBackend)); + + db.init(test::utils::getConnectionParamter(currentBackend)); + + test::KeyValueTable *table = static_cast< test::KeyValueTable * >(db.getTable(id)); + QVERIFY(table != nullptr); + + table->insert("test", "value"); + QCOMPARE(table->query("test"), std::string("value")); + } +} + +void DatabaseTest::unicode() { + for (Backend currentBackend : backends) { + qInfo() << "Current backend:" << QString::fromStdString(backendToString(currentBackend)); + + TestDatabase db(currentBackend); + + Database::table_id id = db.addTable(std::make_unique< test::KeyValueTable >(db.getSQLHandle(), currentBackend)); + + db.init(test::utils::getConnectionParamter(currentBackend)); + + test::KeyValueTable *table = static_cast< test::KeyValueTable * >(db.getTable(id)); + QVERIFY(table != nullptr); + + for (const std::string &symbol : + { "Ä", "è", "ß", "¼", "💚", "🚒", "ձ", "Φ", "ヲ", "𐌒", "Ю", "¥", "ℏ", "ℜ", "¯_(ツ)_/¯" }) { + qInfo() << "Current symbol:" << QString::fromStdString(symbol); + + std::string unicodeValue = "Some string with " + symbol; + std::string unicodeKey = "Key with " + symbol; + + table->insert("testKey", unicodeValue); + QCOMPARE(table->query("testKey"), unicodeValue); + table->clear(); + + table->insert(unicodeKey, "TestValue"); + QCOMPARE(table->query(unicodeKey), std::string("TestValue")); + table->clear(); + + table->insert(unicodeKey, unicodeValue); + QCOMPARE(table->query(unicodeKey), unicodeValue); + table->clear(); + + table->insert(symbol, symbol); + QCOMPARE(table->query(symbol), symbol); + table->clear(); + } + } +} + +QTEST_MAIN(DatabaseTest) +#include "DatabaseTest.moc" diff --git a/src/tests/TestDatabase/DefaultTable.cpp b/src/tests/TestDatabase/DefaultTable.cpp new file mode 100644 index 000000000..5becc9c1a --- /dev/null +++ b/src/tests/TestDatabase/DefaultTable.cpp @@ -0,0 +1,53 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "DefaultTable.h" +#include "database/AccessException.h" +#include "database/Column.h" +#include "database/DataType.h" +#include "database/Utils.h" + +#include + +namespace mumble { +namespace db { + namespace test { + + constexpr const char *DefaultTable::NAME; + constexpr const char *DefaultTable::DEFAULT; + + DefaultTable::DefaultTable(soci::session &sql, Backend backend) : Table(sql, backend, NAME) { + std::vector< Column > columns; + + columns.push_back(Column("DefaultColKey", DataType(DataType::String, 100))); + columns.push_back(Column("DefaultColValue", DataType(DataType::String, 100), {}, DEFAULT)); + + setColumns(columns); + } + + void DefaultTable::insert(const std::string &key) { + try { + m_sql << "INSERT INTO \"" + getName() + "\" (DefaultColKey) VALUES (:key)", soci::use(key); + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at inserting default value: ") + e.what()); + } + } + + std::string DefaultTable::select(const std::string &key) { + try { + std::string value; + m_sql << "SELECT DefaultColValue FROM \"" + getName() + "\" WHERE DefaultColKey = :key", soci::use(key), + soci::into(value); + + ::mumble::db::utils::verifyQueryResultedInData(m_sql); + + return value; + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at inserting default value: ") + e.what()); + } + } + } // namespace test +} // namespace db +} // namespace mumble diff --git a/src/tests/TestDatabase/DefaultTable.h b/src/tests/TestDatabase/DefaultTable.h new file mode 100644 index 000000000..9d58ac56a --- /dev/null +++ b/src/tests/TestDatabase/DefaultTable.h @@ -0,0 +1,38 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_TEST_TESTDATABASE_DEFAULTTABLE_H_ +#define MUMBLE_TEST_TESTDATABASE_DEFAULTTABLE_H_ + +#include "database/Backend.h" +#include "database/Table.h" + +#include + +namespace soci { +class session; +} + +namespace mumble { +namespace db { + namespace test { + + class DefaultTable : public Table { + public: + static constexpr const char *NAME = "DefaultTable"; + // Use a default that is using spaces and different quotation marks (to make it more difficult) + static constexpr const char *DEFAULT = "My sister's uncle is called \"Sam\""; + + DefaultTable(soci::session &sql, Backend backend); + ~DefaultTable() = default; + + void insert(const std::string &key); + std::string select(const std::string &key); + }; + } // namespace test +} // namespace db +} // namespace mumble + +#endif // MUMBLE_TEST_TESTDATABASE_DEFAULTTABLE_H_ diff --git a/src/tests/TestDatabase/KeyValueTable.cpp b/src/tests/TestDatabase/KeyValueTable.cpp new file mode 100644 index 000000000..65cf458b4 --- /dev/null +++ b/src/tests/TestDatabase/KeyValueTable.cpp @@ -0,0 +1,52 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "KeyValueTable.h" +#include "database/AccessException.h" +#include "database/Column.h" + +#include + +#include + +namespace mumble { +namespace db { + namespace test { + + constexpr const char *KeyValueTable::NAME; + + KeyValueTable::KeyValueTable(soci::session &sql, Backend backend) : Table(sql, backend, NAME) { + std::vector< Column > columns; + + columns.push_back(Column("key_col", DataType(DataType::String, 150))); + columns.push_back(Column("value_col", DataType(DataType::String, 150))); + + setColumns(columns); + } + + void KeyValueTable::insert(const std::string &key, const std::string &value) { + try { + m_sql << "INSERT INTO \"" + getName() + "\" (key_col, value_col) VALUES (:key, :value)", soci::use(key), + soci::use(value); + } catch (const soci::soci_error &e) { + throw AccessException(std::string("Failed at inserting key-value-pair: ") + e.what()); + } + } + + std::string KeyValueTable::query(const std::string &key) { + std::string value; + try { + m_sql << "SELECT value_col FROM \"" + getName() + "\" WHERE key_col = :key", soci::use(key), + soci::into(value); + } catch (const soci::soci_error &e) { + throw AccessException("Failed at querying value for key \"" + key + "\": " + e.what()); + } + + return value; + } + + } // namespace test +} // namespace db +} // namespace mumble diff --git a/src/tests/TestDatabase/KeyValueTable.h b/src/tests/TestDatabase/KeyValueTable.h new file mode 100644 index 000000000..c54858c64 --- /dev/null +++ b/src/tests/TestDatabase/KeyValueTable.h @@ -0,0 +1,35 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_TEST_TESTDATABASE_KEYVALUE_TABLE_H_ +#define MUMBLE_TEST_TESTDATABASE_KEYVALUE_TABLE_H_ + +#include "database/Backend.h" +#include "database/Table.h" + +#include + +namespace soci { +class session; +} + +namespace mumble { +namespace db { + namespace test { + class KeyValueTable : public Table { + public: + static constexpr const char *NAME = "keyValueTable"; + + KeyValueTable(soci::session &sql, Backend backend); + ~KeyValueTable() = default; + + void insert(const std::string &key, const std::string &value); + std::string query(const std::string &key); + }; + } // namespace test +} // namespace db +} // namespace mumble + +#endif // MUMBLE_TEST_TESTDATABASE_KEYVALUE_TABLE_H_ diff --git a/src/tests/TestDatabase/README.md b/src/tests/TestDatabase/README.md new file mode 100644 index 000000000..152d2c293 --- /dev/null +++ b/src/tests/TestDatabase/README.md @@ -0,0 +1,61 @@ +# Database tests + +This directory contains test cases for Mumble's database (backend) implementation. The tests can (and generally should) be performed for multiple +database flavors: SQLite, MySQL and PostgreSQL. However for the latter two, it is necessary to perform some initial setup in order for the test +program to obtain access to the respective databases. + +In order to avoid failing tests due to incorrect (or missing) setup, the tests for MySQL and PostgreSQL are disabled by default, but they can be +enabled via the `database-mysql-test` and `database-postgresql-test` cmake options (just set these to `ON` when invoking cmake). + + +## Required setup + +The setup is very similar for MySQL and PostgreSQL. Remember that you need to execute the following instructions via an existing account with the +necessary privileges. On standard Ubuntu installations, this is most easily accomplished by connecting to the DB via `sudo mysql` and +`sudo -u postgres psql` respectively. + +### MySQL + +1. Create a database with the name `mumble_test_db`: +```sql +CREATE DATABASE mumble_test_db; +``` + +2. Create a new user with the name `mumble_test_user` and password `MumbleTestPassword` +```sql +CREATE USER 'mumble_test_user'@'localhost' IDENTIFIED BY 'MumbleTestPassword'; +``` + +3. Grant this new user all privileges on the test database: +```sql +GRANT ALL PRIVILEGES ON mumble_test_db.* TO 'mumble_test_user'@'localhost'; +``` + +4. Enable `log-bin-trust-function-creators` as otherwise creating triggers and stored functions would require global `SUPER` privilege for the Mumble + user. To enable this mode, edit `/etc/mysql/my.cnf` and add +``` +[mysqld] +log-bin-trust-function-creators = 1 +``` + to that file. Afterwards, restart the MySQL daemon via `systemctl restart mysql`. + + Note that Mumble's database backend will take care of using a binlog format that should not cause issues with this setting. + + +### PostgreSQL + +1. Create a database with the name `mumble_test_db`: +```sql +CREATE DATABASE mumble_test_db; +``` + +2. Create a new user with the name `mumble_test_user` and password `MumbleTestPassword` +```sql +CREATE USER mumble_test_user ENCRYPTED PASSWORD 'MumbleTestPassword'; +``` + +3. Grant this new user all privileges on the test database: +```sql +GRANT ALL PRIVILEGES ON DATABASE mumble_test_db TO mumble_test_user; +``` + diff --git a/src/tests/TestDatabase/TestUtils.cpp b/src/tests/TestDatabase/TestUtils.cpp new file mode 100644 index 000000000..a204b9e75 --- /dev/null +++ b/src/tests/TestDatabase/TestUtils.cpp @@ -0,0 +1,88 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "TestUtils.h" +#include "database/MySQLConnectionParameter.h" +#include "database/PostgreSQLConnectionParameter.h" +#include "database/SQLiteConnectionParameter.h" + +#include + +namespace mumble { +namespace db { + namespace test { + namespace utils { + + /** + * Aligns the column order of the two provided JSON representation of a DB's content, so that + * the columns appear in the same order (if they were only a permutation of each other to begin with). + * All related fields (column_types and all row entries) are permuted accordingly to match the new + * column order. + * + * This function has to be called, before checking reference and target for equality, as the column + * order in our DB JSON representation is to some degree arbitrary, but the JSON objects compare + * them in an order-aware fashion (as they are stored as arrays). + */ + void alignColumnOrder(const nlohmann::json &reference, nlohmann::json &target) { + for (auto it = reference["tables"].begin(); it != reference["tables"].end(); ++it) { + const nlohmann::json &referenceTable = it.value(); + nlohmann::json &targetTable = target["tables"][it.key()]; + + if (referenceTable["column_names"].size() != targetTable["column_names"].size() + || !std::is_permutation(referenceTable["column_names"].begin(), + referenceTable["column_names"].end(), + targetTable["column_names"].begin())) { + // If the column names are not a simple permutation of each other, these tables are not equal + // in any case, so we don't even try to align their ordering + continue; + } + + std::vector< std::size_t > permutation = + test::utils::find_permutation(referenceTable["column_names"], targetTable["column_names"]); + + // First apply to column_names itself + test::utils::apply_permutation(targetTable["column_names"], permutation); + // Then in the same way to column_types + test::utils::apply_permutation(targetTable["column_types"], permutation); + + for (nlohmann::json ¤tRow : targetTable["rows"]) { + test::utils::apply_permutation(currentRow, permutation); + } + } + } + + + const mumble::db::ConnectionParameter &getConnectionParamter(mumble::db::Backend backend) { + switch (backend) { + case mumble::db::Backend::SQLite: { + static mumble::db::SQLiteConnectionParameter param("mumble_test_db"); + + return param; + } + case mumble::db::Backend::MySQL: { + static mumble::db::MySQLConnectionParameter param("mumble_test_db"); + + param.userName = "mumble_test_user"; + param.password = "MumbleTestPassword"; + + return param; + } + case mumble::db::Backend::PostgreSQL: { + static mumble::db::PostgreSQLConnectionParameter param("mumble_test_db"); + + param.userName = "mumble_test_user"; + param.password = "MumbleTestPassword"; + + return param; + } + } + + throw std::runtime_error("Not all backend types covered in test!"); + } + + } // namespace utils + } // namespace test +} // namespace db +} // namespace mumble diff --git a/src/tests/TestDatabase/TestUtils.h b/src/tests/TestDatabase/TestUtils.h new file mode 100644 index 000000000..f8484a638 --- /dev/null +++ b/src/tests/TestDatabase/TestUtils.h @@ -0,0 +1,91 @@ +// Copyright 2022 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_TEST_TESTDATABASE_UTILS_H_ +#define MUMBLE_TEST_TESTDATABASE_UTILS_H_ + +#include "database/Backend.h" +#include "database/ConnectionParameter.h" + +#include +#include +#include +#include + +#include + +namespace mumble { +namespace db { + namespace test { + namespace utils { + + // Implementations for find_permutation and apply_permutation taken/inspired from/by + // https://stackoverflow.com/a/17074810 + template< typename VectorLike > + std::vector< std::size_t > find_permutation(const VectorLike &reference, const VectorLike &target) { + assert(reference.size() == target.size()); + assert(std::is_permutation(reference.begin(), reference.end(), target.begin())); + + std::vector< std::size_t > permutation(target.size()); + std::iota(permutation.begin(), permutation.end(), 0); + + assert(std::is_sorted(permutation.begin(), permutation.end())); + + do { + bool matches = true; + for (std::size_t i = 0; i < permutation.size(); ++i) { + if (reference[i] != target[permutation[i]]) { + matches = false; + break; + } + } + + if (matches) { + break; + } + } while (std::next_permutation(permutation.begin(), permutation.end())); + + return permutation; + } + + template< typename VectorLike > + void apply_permutation(VectorLike &vec, const std::vector< std::size_t > &permutation) { + std::vector< bool > done(vec.size()); + for (std::size_t i = 0; i < vec.size(); ++i) { + if (done[i]) { + continue; + } + done[i] = true; + std::size_t prev_j = i; + std::size_t j = permutation[i]; + while (i != j) { + std::swap(vec[prev_j], vec[j]); + done[j] = true; + prev_j = j; + j = permutation[j]; + } + } + } + + /** + * Aligns the column order of the two provided JSON representation of a DB's content, so that + * the columns appear in the same order (if they were only a permutation of each other to begin with). + * All related fields (column_types and all row entries) are permuted accordingly to match the new + * column order. + * + * This function has to be called, before checking reference and target for equality, as the column + * order in our DB JSON representation is to some degree arbitrary, but the JSON objects compare + * them in an order-aware fashion (as they are stored as arrays). + */ + void alignColumnOrder(const nlohmann::json &reference, nlohmann::json &target); + + const mumble::db::ConnectionParameter &getConnectionParamter(mumble::db::Backend backend); + + } // namespace utils + } // namespace test +} // namespace db +} // namespace mumble + +#endif // MUMBLE_TEST_TESTDATABASE_UTILS_H_