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.
This commit is contained in:
Robert Adam 2022-02-10 20:10:49 +01:00
parent 4985b18d6b
commit 2521806ee6
59 changed files with 4320 additions and 10 deletions

View File

@ -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

View File

@ -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'

3
.gitmodules vendored
View File

@ -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

1
3rdparty/soci vendored Submodule

@ -0,0 +1 @@
Subproject commit 438e3549594eb59d84b434c814647648e7c2f10a

View File

@ -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
)

View File

@ -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()

20
src/NonCopyable.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#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_

44
src/database/Backend.cpp Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "Backend.h"
#include <boost/algorithm/string.hpp>
#include <cassert>
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

53
src/database/Backend.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_BACKEND_H_
#define MUMBLE_DATABASE_BACKEND_H_
#include "Exception.h"
#include <string>
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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
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)

49
src/database/Column.cpp Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "Column.h"
#include <cassert>
#include <boost/algorithm/string.hpp>
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

48
src/database/Column.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_COLUMN_H_
#define MUMBLE_DATABASE_COLUMN_H_
#include "Constraint.h"
#include "DataType.h"
#include <string>
#include <vector>
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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_CONNECTIONPARAMETER_H_
#define MUMBLE_DATABASE_CONNECTIONPARAMETER_H_
#include "database/Backend.h"
#include <string>
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_

19
src/database/Constants.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_CONSTANTS_H_
#define MUMBLE_DATABASE_CONSTANTS_H_
#include <string>
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_

216
src/database/Constraint.cpp Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "Constraint.h"
#include "Backend.h"
#include "Column.h"
#include "DataType.h"
#include "Table.h"
#include "UnsupportedOperationException.h"
#include <algorithm>
#include <cassert>
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 <name>" 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

59
src/database/Constraint.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_CONSTRAINT_H_
#define MUMBLE_DATABASE_CONSTRAINT_H_
#include "Backend.h"
#include <string>
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_

84
src/database/DBUtils.cpp Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "DBUtils.h"
#include <soci/soci.h>
#include <nlohmann/json.hpp>
#include <cassert>
#include <iostream>
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

30
src/database/DBUtils.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_UTILS_H_
#define MUMBLE_DATABASE_UTILS_H_
#include <string>
#include <nlohmann/json_fwd.hpp>
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_

158
src/database/DataType.cpp Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "DataType.h"
#include "Backend.h"
#include <boost/algorithm/string.hpp>
#include <cassert>
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

59
src/database/DataType.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_DATATYPE_H_
#define MUMBLE_DATABASE_DATATYPE_H_
#include "Exception.h"
#include <limits>
#include <string>
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_

525
src/database/Database.cpp Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#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 <algorithm>
#include <cassert>
#include <cstdio>
#include <memory>
#include <nlohmann/json.hpp>
#include <boost/algorithm/string.hpp>
// 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 &parameter) {
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 > &currentTable : m_tables) {
tables[currentTable->getName()] = currentTable->exportToJSON();
}
return json;
}
void Database::destroyTables() {
for (const std::unique_ptr< Table > &currentTable : m_tables) {
if (currentTable) {
if (tableExistsInDB(currentTable->getName())) {
currentTable->destroy();
}
}
}
}
void Database::clearTables() {
for (const std::unique_ptr< Table > &currentTable : 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 &parameter) {
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 &parameter) {
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 > &currentTable : 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 > &currentTable : 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

95
src/database/Database.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#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 <memory>
#include <vector>
#include <soci/soci.h>
#include <nlohmann/json_fwd.hpp>
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 &parameter);
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 &parameter);
bool tableExistsInDB(const std::string &name);
void applyBackendSpecificSetup(const ConnectionParameter &parameter);
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_

24
src/database/Exception.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_EXCEPTION_H_
#define MUMBLE_DATABASE_EXCEPTION_H_
#include <stdexcept>
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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#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_

64
src/database/Index.cpp Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "Index.h"
#include "Table.h"
#include <cassert>
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

41
src/database/Index.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_INDEX_H_
#define MUMBLE_DATABASE_INDEX_H_
#include "Backend.h"
#include <string>
#include <vector>
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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "MetaTable.h"
#include "AccessException.h"
#include "Column.h"
#include "DataType.h"
#include "InitException.h"
#include <soci/soci.h>
#include "FormatException.h"
#include <nlohmann/json.hpp>
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

27
src/database/MetaTable.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "database/MySQLConnectionParameter.h"
#include "database/Backend.h"
#include <string>
namespace mumble {
namespace db {
MySQLConnectionParameter::MySQLConnectionParameter(const std::string &dbName) : dbName(dbName) {}
Backend MySQLConnectionParameter::applicability() const { return Backend::MySQL; }
} // namespace db
} // namespace mumble

View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_MYSQLCONNECTIONPARAMETER_H_
#define MUMBLE_DATABASE_MYSQLCONNECTIONPARAMETER_H_
#include "database/Backend.h"
#include "database/ConnectionParameter.h"
#include <string>
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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "database/PostgreSQLConnectionParameter.h"
#include "database/Backend.h"
#include <string>
namespace mumble {
namespace db {
PostgreSQLConnectionParameter::PostgreSQLConnectionParameter(const std::string &dbName) : dbName(dbName) {}
Backend PostgreSQLConnectionParameter::applicability() const { return Backend::PostgreSQL; }
} // namespace db
} // namespace mumble

View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_POSTGRESQLCONNECTIONPARAMETER_H_
#define MUMBLE_DATABASE_POSTGRESQLCONNECTIONPARAMETER_H_
#include "database/Backend.h"
#include "database/ConnectionParameter.h"
#include <string>
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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "SQLiteConnectionParameter.h"
#include <boost/algorithm/string.hpp>
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

View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_SQLITECONNECTIONPARAMTER_H_
#define MUMBLE_DATABASE_SQLITECONNECTIONPARAMTER_H_
#include "database/Backend.h"
#include "database/ConnectionParameter.h"
#include <string>
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_

1
src/database/TODO Normal file
View File

@ -0,0 +1 @@
- Test DB migration (in general)

418
src/database/Table.cpp Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "Table.h"
#include "AccessException.h"
#include "DBUtils.h"
#include "FormatException.h"
#include <soci/soci.h>
#include <boost/algorithm/string.hpp>
#include <nlohmann/json.hpp>
#include <cassert>
#include <utility>
#include <vector>
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 &currentCol : m_columns) {
if (currentCol.getName() == name) {
return &currentCol;
}
}
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 &currentColumn : 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 &currentConstraint : 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 &currentIndex : 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 > &currentPair : 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 &currentRow = 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 &currentRow : 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 &currentVal : 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 &currentColumn : 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 &currentRow = *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 &currentColumn : 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

82
src/database/Table.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_TABLE_H_
#define MUMBLE_DATABASE_TABLE_H_
#include "Backend.h"
#include "Column.h"
#include "Index.h"
#include "Trigger.h"
#include <string>
#include <vector>
#include <nlohmann/json_fwd.hpp>
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_

102
src/database/Trigger.cpp Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "Trigger.h"
#include "Table.h"
#include <cassert>
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

61
src/database/Trigger.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_TRIGGER_H_
#define MUMBLE_DATABASE_TRIGGER_H_
#include "Backend.h"
#include <string>
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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#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_

33
src/database/Utils.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_UTILS_H_
#define MUMBLE_DATABASE_UTILS_H_
#include "NoDataException.h"
#include <soci/soci.h>
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_

42
src/database/Version.cpp Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#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

35
src/database/Version.h Normal file
View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_DATABASE_VERSION_H_
#define MUMBLE_DATABASE_VERSION_H_
#include <string>
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_

View File

@ -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)

View File

@ -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")

View File

@ -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 <https://www.mumble.info/LICENSE>.
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 $<TARGET_FILE:TestDatabase>)

View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "ConstraintTable.h"
#include "database/AccessException.h"
#include "database/Column.h"
#include "database/Constraint.h"
#include "database/Utils.h"
#include <soci/soci.h>
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

View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_TEST_TESTDATABASE_CONTRAINTTABLE_H_
#define MUMBLE_TEST_TESTDATABASE_CONTRAINTTABLE_H_
#include "database/Backend.h"
#include "database/Table.h"
#include <string>
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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#include <QString>
#include <QtTest>
#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 <nlohmann/json.hpp>
#include <algorithm>
#include <cstring>
#include <vector>
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 &param) { 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 &currentConstraint : 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"

View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "DefaultTable.h"
#include "database/AccessException.h"
#include "database/Column.h"
#include "database/DataType.h"
#include "database/Utils.h"
#include <soci/soci.h>
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

View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_TEST_TESTDATABASE_DEFAULTTABLE_H_
#define MUMBLE_TEST_TESTDATABASE_DEFAULTTABLE_H_
#include "database/Backend.h"
#include "database/Table.h"
#include <string>
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_

View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "KeyValueTable.h"
#include "database/AccessException.h"
#include "database/Column.h"
#include <vector>
#include <soci/soci.h>
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

View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_TEST_TESTDATABASE_KEYVALUE_TABLE_H_
#define MUMBLE_TEST_TESTDATABASE_KEYVALUE_TABLE_H_
#include "database/Backend.h"
#include "database/Table.h"
#include <string>
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_

View File

@ -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;
```

View File

@ -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 <https://www.mumble.info/LICENSE>.
#include "TestUtils.h"
#include "database/MySQLConnectionParameter.h"
#include "database/PostgreSQLConnectionParameter.h"
#include "database/SQLiteConnectionParameter.h"
#include <nlohmann/json.hpp>
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 &currentRow : 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

View File

@ -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 <https://www.mumble.info/LICENSE>.
#ifndef MUMBLE_TEST_TESTDATABASE_UTILS_H_
#define MUMBLE_TEST_TESTDATABASE_UTILS_H_
#include "database/Backend.h"
#include "database/ConnectionParameter.h"
#include <algorithm>
#include <cassert>
#include <numeric>
#include <vector>
#include <nlohmann/json_fwd.hpp>
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_