From fc0bad81c44344ce9a6990ab7149791e593dcfd3 Mon Sep 17 00:00:00 2001 From: khodowany Date: Fri, 4 Sep 2026 19:30:11 -0500 Subject: [PATCH] Initial public khodo snapshot --- .gitea/workflows/ci.yml | 105 ++ .gitignore | 20 + CMakeLists.txt | 72 ++ LICENSE | 21 + README.md | 43 + cmake/noto_config.cmake.in | 6 + conanfile.py | 107 ++ include/noto.hpp | 8 + include/noto/config.hpp | 48 + include/noto/connection.hpp | 199 ++++ include/noto/detail/core.hpp | 83 ++ include/noto/detail/errors.hpp | 59 + include/noto/detail/sql.hpp | 13 + include/noto/errors.hpp | 16 + include/noto/migration.hpp | 47 + include/noto/schema.hpp | 146 +++ include/noto/transaction.hpp | 47 + modulus.json | 11 + modulus.py | 367 ++++++ src/config.cpp | 361 ++++++ src/connection.cpp | 593 ++++++++++ src/detail/core.cpp | 2024 ++++++++++++++++++++++++++++++++ src/detail/errors.cpp | 26 + src/detail/sql.cpp | 32 + src/errors.cpp | 79 ++ src/migration.cpp | 322 +++++ src/schema.cpp | 523 +++++++++ src/transaction.cpp | 111 ++ tests/CMakeLists.txt | 26 + tests/test.cpp | 1149 ++++++++++++++++++ tests/test.hpp | 4 + 31 files changed, 6668 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100755 .gitignore create mode 100755 CMakeLists.txt create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmake/noto_config.cmake.in create mode 100644 conanfile.py create mode 100644 include/noto.hpp create mode 100644 include/noto/config.hpp create mode 100644 include/noto/connection.hpp create mode 100644 include/noto/detail/core.hpp create mode 100644 include/noto/detail/errors.hpp create mode 100644 include/noto/detail/sql.hpp create mode 100644 include/noto/errors.hpp create mode 100644 include/noto/migration.hpp create mode 100644 include/noto/schema.hpp create mode 100644 include/noto/transaction.hpp create mode 100644 modulus.json create mode 100755 modulus.py create mode 100644 src/config.cpp create mode 100644 src/connection.cpp create mode 100644 src/detail/core.cpp create mode 100644 src/detail/errors.cpp create mode 100644 src/detail/sql.cpp create mode 100644 src/errors.cpp create mode 100644 src/migration.cpp create mode 100644 src/schema.cpp create mode 100644 src/transaction.cpp create mode 100755 tests/CMakeLists.txt create mode 100644 tests/test.cpp create mode 100644 tests/test.hpp diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..80f5db3 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,105 @@ +name: publish + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: write + actions: write + +env: + CONAN_HOME: build/build_common/conan_cache + MODULUS_DIRECT: "1" + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install build dependencies + run: | + if [ -r /etc/os-release ]; then + . /etc/os-release + fi + if [ "${ID:-}" = "ubuntu" ] && [ -n "${VERSION_CODENAME:-}" ]; then + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sed -i '/^Types: deb$/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + printf '%s\n' \ + 'Types: deb' \ + 'URIs: http://ports.ubuntu.com/ubuntu-ports/' \ + "Suites: ${VERSION_CODENAME} ${VERSION_CODENAME}-updates ${VERSION_CODENAME}-backports" \ + 'Components: main universe restricted multiverse' \ + 'Architectures: arm64' \ + 'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \ + '' \ + 'Types: deb' \ + 'URIs: http://ports.ubuntu.com/ubuntu-ports/' \ + "Suites: ${VERSION_CODENAME}-security" \ + 'Components: main universe restricted multiverse' \ + 'Architectures: arm64' \ + 'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/ubuntu-arm64.sources + elif [ -f /etc/apt/sources.list ]; then + sed -i 's|^deb http://archive.ubuntu.com/ubuntu|deb [arch=amd64] http://archive.ubuntu.com/ubuntu|' /etc/apt/sources.list + sed -i 's|^deb http://security.ubuntu.com/ubuntu|deb [arch=amd64] http://security.ubuntu.com/ubuntu|' /etc/apt/sources.list + printf '%s\n' \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME} main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-updates main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-backports main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-security main universe restricted multiverse" \ + > /etc/apt/sources.list.d/ubuntu-arm64.list + fi + fi + dpkg --add-architecture arm64 + apt-get update + apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + g++-12 \ + g++-aarch64-linux-gnu \ + gcc-12 \ + gcc-aarch64-linux-gnu \ + git \ + libdrm-dev \ + libdrm-dev:arm64 \ + libegl-dev \ + libegl-dev:arm64 \ + libgbm-dev \ + libgbm-dev:arm64 \ + libgles-dev \ + libgles-dev:arm64 \ + libglvnd-dev \ + libglvnd-dev:arm64 \ + ninja-build \ + pkg-config \ + python3 \ + python3-venv + python3 -m venv build/build_common/ci_python + build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 + echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH" + - name: Restore Conan cache + uses: actions/cache@v4 + with: + path: build/build_common/conan_cache + key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json') }} + restore-keys: conan-${{ runner.os }}- + - name: Configure native release + run: python3 modulus.py configure --target x86_64_gcc12_release + - name: Build native release + run: python3 modulus.py build --target x86_64_gcc12_release + - name: Test native release + run: python3 modulus.py test --target x86_64_gcc12_release + - name: Create and upload packages + if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }} + env: + CONAN_USER: ${{ secrets.CONAN_USER }} + CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }} + NOTO_CONAN_UPLOAD: "1" + MODULUS_ALLOW_PACKAGE_REPLACE: "1" + run: python3 modulus.py package --all --replace diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..f782367 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +build/ +.clangd +.clang-format +pyrightconfig.json + +**/__pycache__/ +*.pyc +*.pyo +*.pyd +*.log + +package/CMakeCache.txt +package/CMakeFiles/ +package/metadata/ +package/.ninja_* +package/*.json +package/*.cmake +package/*.ninja +package/*.sh +package/*-smoke diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100755 index 0000000..3a551f8 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,72 @@ +cmake_minimum_required(VERSION 3.25) + +project(noto VERSION 0.1.0 LANGUAGES CXX) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) +include(CTest) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(modulus CONFIG REQUIRED) + +add_library(noto + src/detail/core.cpp + src/detail/errors.cpp + src/detail/sql.cpp + src/connection.cpp + src/config.cpp + src/errors.cpp + src/migration.cpp + src/schema.cpp + src/transaction.cpp +) +target_include_directories(noto PUBLIC + $ + $ +) +target_compile_features(noto PUBLIC cxx_std_23) +target_link_libraries(noto PUBLIC + modulus::modulus + modulus::boost_json + modulus::sqlite +) +add_library(noto::noto ALIAS noto) + +if(BUILD_TESTING) + add_subdirectory(tests) +endif() + +install(TARGETS noto + EXPORT noto-targets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) + +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +install(EXPORT noto-targets + NAMESPACE noto:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/noto +) + +configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/noto_config.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/noto-config.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/noto +) + +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/noto-config-version.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) + +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/noto-config.cmake + ${CMAKE_CURRENT_BINARY_DIR}/noto-config-version.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/noto +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f93603 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Khodo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b10046 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Noto + +Noto is a C++23 embedded SQLite database management library. It provides connections, transactions, prepared statements, schema helpers, migrations, backup/export helpers, integrity checks, PRAGMA helpers, and `std::expected`-based error reporting. + +Noto uses Modulus for shared build tooling, Boost.JSON, and SQLite integration. Product-specific schemas and migrations belong in consuming applications. + +## Build + +Configure, build, and test the default target: + +```sh +python3 modulus.py configure +python3 modulus.py build +python3 modulus.py test +``` + +Release verification: + +```sh +python3 modulus.py configure --target x86_64_gcc12_release +python3 modulus.py build --target x86_64_gcc12_release +python3 modulus.py test --target x86_64_gcc12_release +``` + +Create packages: + +```sh +python3 modulus.py package --all +``` + +Configure/build/test can read public packages anonymously from the khodo Conan registry. Publishing requires credentials only on the upload step: + +```sh +CONAN_USER= \ +CONAN_TOKEN= \ +NOTO_CONAN_UPLOAD=1 \ +MODULUS_ALLOW_PACKAGE_REPLACE=1 \ +python3 modulus.py package --all --replace +``` + +## License + +Noto is released under the MIT License. diff --git a/cmake/noto_config.cmake.in b/cmake/noto_config.cmake.in new file mode 100644 index 0000000..00f580f --- /dev/null +++ b/cmake/noto_config.cmake.in @@ -0,0 +1,6 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(modulus CONFIG REQUIRED) + +include("${CMAKE_CURRENT_LIST_DIR}/noto-targets.cmake") diff --git a/conanfile.py b/conanfile.py new file mode 100644 index 0000000..2bc05b0 --- /dev/null +++ b/conanfile.py @@ -0,0 +1,107 @@ +import conan +import conan.tools.build +import conan.tools.cmake + +class noto_conan(conan.ConanFile): + name = "noto" + version = "latest" + description = "C++23 embedded SQLite database management library" + license = "MIT" + package_type = "library" + settings = "os", "arch", "compiler", "build_type" + options = { + "shared": [ + True, + False, + ], + "fPIC": [ + True, + False, + ], + "with_tests": [ + True, + False, + ], + } + default_options = { + "shared": False, + "fPIC": True, + "with_tests": False, + } + exports = "modulus.json" + exports_sources = ( + "CMakeLists.txt", + "cmake/*.in", + "include/*.hpp", + "include/noto/*.hpp", + "include/noto/detail/*.hpp", + "src/*.cpp", + "src/*.hpp", + "tests/CMakeLists.txt", + "tests/*.cpp", + "tests/*.hpp", + ) + + def config_options(self): + if self.settings.os == "Windows": + del self.options.fPIC + + def configure(self): + if self.options.shared: + del self.options.fPIC + + def requirements(self): + self.requires( + "modulus/latest", + transitive_headers=True, + transitive_libs=True, + ) + + def generate(self): + deps = conan.tools.cmake.CMakeDeps(self) + deps.generate() + toolchain = conan.tools.cmake.CMakeToolchain(self) + if hasattr(toolchain, "user_presets_path"): + toolchain.user_presets_path = False + toolchain.variables["BUILD_SHARED_LIBS"] = bool(self.options.shared) + toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests) + toolchain.generate() + + def build(self): + cmake = conan.tools.cmake.CMake(self) + cmake.configure() + cmake.build() + if self.options.with_tests and conan.tools.build.can_run(self): + cmake.test(target="check") + + def package_id(self): + self.info.options.rm_safe("with_tests") + for requirement in ( + "openssl", + "raylib", + "glfw", + "opengl", + "miniaudio", + ): + if requirement in self.info.requires.pkg_names: + self.info.requires.remove(requirement) + + def package(self): + cmake = conan.tools.cmake.CMake(self) + cmake.install() + + def package_info(self): + self.cpp_info.set_property( + "cmake_file_name", + "noto", + ) + self.cpp_info.set_property( + "cmake_target_name", + "noto::noto", + ) + self.cpp_info.libs = ["noto"] + self.cpp_info.requires = [ + "modulus::modulus", + "modulus::boost_json", + "modulus::sqlite", + ] diff --git a/include/noto.hpp b/include/noto.hpp new file mode 100644 index 0000000..6bb9637 --- /dev/null +++ b/include/noto.hpp @@ -0,0 +1,8 @@ +#pragma once + +#include +#include +#include +#include +#include +#include diff --git a/include/noto/config.hpp b/include/noto/config.hpp new file mode 100644 index 0000000..15283ec --- /dev/null +++ b/include/noto/config.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace noto { + +enum class open_mode { + READ_ONLY, + READ_WRITE, + READ_WRITE_CREATE, + MEMORY, +}; + +struct pragma_setting { + std::string name{}; + std::variant value{}; +}; + +struct connection_config { + std::string name{}; + std::string filename{":memory:"}; + noto::open_mode mode{noto::open_mode::READ_WRITE_CREATE}; + std::bool_t uri{false}; + std::chrono::milliseconds busy_timeout{std::chrono::seconds{5}}; + std::bool_t foreign_keys{true}; + std::vector pragmas{}; +}; + +auto validate(noto::connection_config const& config) -> std::expected; +auto memory_connection_config(std::string name = {}) -> noto::connection_config; +auto file_connection_config(std::string filename, std::string name = {}) -> noto::connection_config; +auto parse_database_config(boost::json::value const& value) -> std::expected; +auto parse_database_configs(boost::json::value const& value) -> std::expected, modulus::error>; +auto load_database_configs(std::string const& path) -> std::expected, modulus::error>; +auto to_json(std::variant const& value) -> boost::json::value; +auto to_json(noto::connection_config const& config) -> boost::json::value; +auto to_json(std::vector const& configs) -> boost::json::value; + +} diff --git a/include/noto/connection.hpp b/include/noto/connection.hpp new file mode 100644 index 0000000..5bdd712 --- /dev/null +++ b/include/noto/connection.hpp @@ -0,0 +1,199 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include +#include +#include +#include + +namespace noto { + +namespace detail { + +struct statement_core; +struct connection_core; + +} + +enum class connection_state { + IDLE, + CONNECTED, + CLOSING, + CLOSED, + FAILED, +}; + +struct sqlite_value { + sqlite_value() = default; + sqlite_value(std::nullptr_t); + sqlite_value(std::int32_t value); + sqlite_value(std::int64_t value); + sqlite_value(std::float64_t value); + sqlite_value(std::char_t const* value); + sqlite_value(std::string value); + sqlite_value(std::string_view value); + sqlite_value(std::vector value); + auto is_null() const -> std::bool_t; + std::variant> value{}; +}; + +struct query_result { + std::vector columns{}; + std::vector> rows{}; + std::int64_t changes{}; + std::int64_t last_insert_rowid{}; +}; + +struct backup_options { + std::string source_name{"main"}; + std::string destination_name{"main"}; + std::int32_t pages_per_step{128}; + std::chrono::milliseconds retry_delay{std::chrono::milliseconds{10}}; + std::optional retry_timeout{}; +}; + +struct execute_options { + std::optional max_rows{100000}; + std::optional max_result_bytes{64ULL * 1024ULL * 1024ULL}; +}; + +struct export_options { + std::optional max_output_bytes{64ULL * 1024ULL * 1024ULL}; + std::size_t rows_per_yield{128}; +}; + +class connection; + +class statement { +public: + statement() = default; + statement(noto::statement const&) = delete; + statement(noto::statement&& other) noexcept = default; + ~statement(); + auto operator=(noto::statement const&) -> noto::statement& = delete; + auto operator=(noto::statement&& other) noexcept -> noto::statement& = default; + auto valid() const -> std::bool_t; + auto async_reset(std::vector params = {}) -> boost::asio::awaitable>; + auto async_step() -> boost::asio::awaitable>, modulus::error>>; + auto async_execute(std::vector params = {}) -> boost::asio::awaitable>; + auto async_execute(std::vector params, noto::execute_options options) -> boost::asio::awaitable>; + auto async_finalize() -> boost::asio::awaitable>; +private: + friend class noto::connection; + friend struct noto::detail::connection_core; + explicit statement(std::shared_ptr core); + std::shared_ptr core_{}; +}; + +class connection { +public: + connection(boost::asio::any_io_executor executor, noto::connection_config config); + connection(noto::connection const&) = delete; + connection(noto::connection&&) noexcept = default; + ~connection(); + auto operator=(noto::connection const&) -> noto::connection& = delete; + auto operator=(noto::connection&&) noexcept -> noto::connection& = default; + auto config() const -> noto::connection_config; + auto state() const -> noto::connection_state; + auto async_connect() -> boost::asio::awaitable>; + auto async_close() -> boost::asio::awaitable>; + auto async_execute(std::string sql, std::vector params = {}) -> boost::asio::awaitable>; + auto async_execute(std::string sql, std::vector params, noto::execute_options options) -> boost::asio::awaitable>; + auto async_prepare(std::string sql) -> boost::asio::awaitable>; + auto async_begin_transaction(noto::transaction_mode mode = noto::transaction_mode::IMMEDIATE) -> boost::asio::awaitable>; + template + auto async_with_transaction(noto::transaction_mode mode, CALLBACK callback) -> boost::asio::awaitable>; + auto async_create_table(noto::table_definition definition) -> boost::asio::awaitable>; + auto async_drop_table(noto::table_name table, std::bool_t if_exists = true) -> boost::asio::awaitable>; + auto async_create_index(noto::index_definition definition) -> boost::asio::awaitable>; + auto async_drop_index(noto::index_definition definition) -> boost::asio::awaitable>; + auto async_backup_to_file(std::string path) -> boost::asio::awaitable>; + auto async_backup_to_file(std::string path, noto::backup_options options) -> boost::asio::awaitable>; + auto async_restore_from_file(std::string path) -> boost::asio::awaitable>; + auto async_restore_from_file(std::string path, noto::backup_options options) -> boost::asio::awaitable>; + auto async_export_sql() -> boost::asio::awaitable>; + auto async_export_sql(noto::export_options options) -> boost::asio::awaitable>; + auto async_export_sql_to_file(std::string path) -> boost::asio::awaitable>; + auto async_export_sql_to_file(std::string path, noto::export_options options) -> boost::asio::awaitable>; + auto async_integrity_check() -> boost::asio::awaitable, modulus::error>>; + auto async_quick_check() -> boost::asio::awaitable, modulus::error>>; + auto async_foreign_key_check() -> boost::asio::awaitable>; + auto async_get_pragma(std::string name) -> boost::asio::awaitable>; + auto async_set_pragma(std::string name, std::variant value) -> boost::asio::awaitable>; + auto async_foreign_keys() -> boost::asio::awaitable>; + auto async_set_foreign_keys(std::bool_t enabled) -> boost::asio::awaitable>; + auto async_busy_timeout() -> boost::asio::awaitable>; + auto async_set_busy_timeout(std::chrono::milliseconds timeout) -> boost::asio::awaitable>; + auto async_user_version() -> boost::asio::awaitable>; + auto async_set_user_version(std::int64_t version) -> boost::asio::awaitable>; + auto async_application_id() -> boost::asio::awaitable>; + auto async_set_application_id(std::int64_t id) -> boost::asio::awaitable>; + auto async_journal_mode(std::string mode) -> boost::asio::awaitable>; + auto async_wal_checkpoint(std::string mode = "PASSIVE") -> boost::asio::awaitable>; +private: + friend class noto::transaction; + explicit connection(std::shared_ptr core); + auto core() const -> std::shared_ptr; + std::shared_ptr core_{}; +}; + +} + +template +auto noto::connection::async_with_transaction(noto::transaction_mode mode, CALLBACK callback) -> boost::asio::awaitable> { + return [](noto::connection connection, noto::transaction_mode mode, CALLBACK callback) -> boost::asio::awaitable> { + auto transaction_result = co_await connection.async_begin_transaction(mode); + if (!transaction_result) { + co_return std::unexpected(transaction_result.error()); + } + auto transaction = std::move(*transaction_result); + auto callback_status = std::expected{}; + auto exception_status = std::optional{}; + try { + callback_status = co_await callback(connection); + } catch (std::exception const& error) { + exception_status = noto::detail::make_error(noto::detail::error_internal, std::string{"transaction callback threw: "} + error.what()); + } catch (...) { + exception_status = noto::detail::make_error(noto::detail::error_internal, "transaction callback threw an unknown exception"); + } + if (exception_status) { + auto rollback = co_await transaction.async_rollback(); + if (!rollback) { + co_return rollback; + } + co_return std::unexpected(*exception_status); + } + if (!callback_status) { + auto rollback = co_await transaction.async_rollback(); + if (!rollback) { + co_return rollback; + } + co_return callback_status; + } + auto commit = co_await transaction.async_commit(); + if (!commit && transaction.active()) { + auto rollback = co_await transaction.async_rollback(); + if (!rollback) { + co_return rollback; + } + } + co_return commit; + }(noto::connection{core_}, mode, std::move(callback)); +} diff --git a/include/noto/detail/core.hpp b/include/noto/detail/core.hpp new file mode 100644 index 0000000..b93221e --- /dev/null +++ b/include/noto/detail/core.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include + +#include +#include +#include + +namespace noto::detail { + +struct transaction_result { + std::expected status{}; + std::bool_t active{}; +}; + +struct connection_core : std::enable_shared_from_this { + connection_core(boost::asio::any_io_executor executor, noto::connection_config config); + ~connection_core(); + auto current_state() const -> noto::connection_state; + auto set_state(noto::connection_state state) -> void; + auto mark_failed(std::string message) -> void; + auto abandon_transaction() -> void; + auto async_connect() -> boost::asio::awaitable>; + auto async_close() -> boost::asio::awaitable>; + auto async_execute(std::string sql, std::vector params, noto::execute_options options) -> boost::asio::awaitable>; + auto async_prepare(std::string sql) -> boost::asio::awaitable>; + auto async_begin_transaction(noto::transaction_mode mode) -> boost::asio::awaitable>; + auto async_commit_transaction() -> boost::asio::awaitable; + auto async_rollback_transaction() -> boost::asio::awaitable; + auto async_backup_to_file(std::string path, noto::backup_options options) -> boost::asio::awaitable>; + auto async_restore_from_file(std::string path, noto::backup_options options) -> boost::asio::awaitable>; + auto async_export_sql(noto::export_options options) -> boost::asio::awaitable>; + auto async_export_sql_to_file(std::string path, noto::export_options options) -> boost::asio::awaitable>; + auto async_set_busy_timeout(std::chrono::milliseconds timeout) -> boost::asio::awaitable>; + boost::asio::any_io_executor executor{}; + boost::asio::strand strand; + noto::connection_config config{}; + std::chrono::milliseconds busy_timeout{}; + sqlite3* database{}; + std::shared_ptr> state{std::make_shared>(noto::connection_state::IDLE)}; + std::bool_t busy{}; + std::bool_t transaction_active{}; + std::atomic_size_t live_statements{}; +}; + +struct statement_core { + statement_core(std::shared_ptr connection, sqlite3_stmt* statement); + ~statement_core(); + auto async_reset(std::vector params) -> boost::asio::awaitable>; + auto async_step() -> boost::asio::awaitable>, modulus::error>>; + auto async_execute(std::vector params, noto::execute_options options) -> boost::asio::awaitable>; + auto async_finalize() -> boost::asio::awaitable>; + std::shared_ptr connection{}; + sqlite3_stmt* statement{}; + std::atomic_bool finalized{}; + std::bool_t counted{}; +}; + +struct transaction_state { + transaction_state(std::shared_ptr core, std::function on_finish = {}); + auto finish() noexcept -> void; + std::shared_ptr core{}; + std::function on_finish{}; + std::atomic_bool active{false}; + std::atomic_bool detached{false}; +}; + +} diff --git a/include/noto/detail/errors.hpp b/include/noto/detail/errors.hpp new file mode 100644 index 0000000..2502fb0 --- /dev/null +++ b/include/noto/detail/errors.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace noto::detail { + +constexpr std::int32_t error_invalid_argument{1}; +constexpr std::int32_t error_not_found{2}; +constexpr std::int32_t error_unavailable{3}; +constexpr std::int32_t error_timeout{4}; +constexpr std::int32_t error_cancelled{5}; +constexpr std::int32_t error_unauthenticated{6}; +constexpr std::int32_t error_permission_denied{7}; +constexpr std::int32_t error_already_exists{8}; +constexpr std::int32_t error_constraint_violation{9}; +constexpr std::int32_t error_syntax_error{10}; +constexpr std::int32_t error_protocol_error{11}; +constexpr std::int32_t error_connection_lost{12}; +constexpr std::int32_t error_data_loss{13}; +constexpr std::int32_t error_resource_exhausted{14}; +constexpr std::int32_t error_internal{15}; + +auto make_error(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error; +auto fail_void(modulus::error error) -> std::expected; +auto fail_void(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected; +auto fail_void(std::expected const& result) -> std::expected; + +template +auto fail(modulus::error error) -> std::expected; + +template +auto fail(std::expected const& result) -> std::expected; + +template +auto fail(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected; + +} + +template +auto noto::detail::fail(modulus::error error) -> std::expected { + return modulus::fail(std::move(error)); +} + +template +auto noto::detail::fail(std::expected const& result) -> std::expected { + assert(!result); + return modulus::fail(result.error()); +} + +template +auto noto::detail::fail(std::int32_t code, std::string message, modulus::source_location location) -> std::expected { + return noto::detail::fail(noto::detail::make_error(code, std::move(message), location)); +} diff --git a/include/noto/detail/sql.hpp b/include/noto/detail/sql.hpp new file mode 100644 index 0000000..5eaf939 --- /dev/null +++ b/include/noto/detail/sql.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include +#include +#include + +#include + +namespace noto::detail { + +auto quote_sql_literal(std::string_view value, std::string_view label, std::bool_t allow_empty = true) -> std::expected; + +} diff --git a/include/noto/errors.hpp b/include/noto/errors.hpp new file mode 100644 index 0000000..be82ef5 --- /dev/null +++ b/include/noto/errors.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +#include +#include + +struct sqlite3; + +namespace noto { + +auto error_from(std::int32_t code, sqlite3* database = nullptr, std::string_view operation = {}) -> modulus::error; +auto result_from(std::int32_t code, sqlite3* database = nullptr, std::string_view operation = {}) -> std::expected; + +} diff --git a/include/noto/migration.hpp b/include/noto/migration.hpp new file mode 100644 index 0000000..6caf7b4 --- /dev/null +++ b/include/noto/migration.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include + +#include +#include + +namespace noto { + +struct migration { + std::string id{}; + std::string description{}; + std::string checksum{}; + std::function>(noto::connection&)> up{}; + std::function>(noto::connection&)> down{}; +}; + +struct migration_record { + std::string id{}; + std::string description{}; + std::string checksum{}; + std::string applied_at{}; +}; + +struct migration_options { + noto::table_name table{{}, "_noto_schema_migrations"}; +}; + +class migrator { +public: + migrator(noto::connection& connection, noto::migration_options options = {}); + auto async_applied_migrations() -> boost::asio::awaitable, modulus::error>>; + auto async_migrate(std::vector migrations) -> boost::asio::awaitable>; + auto async_rollback(std::vector migrations, std::string target_id = {}) -> boost::asio::awaitable>; +private: + noto::connection* connection_{}; + noto::migration_options options_{}; +}; + +} diff --git a/include/noto/schema.hpp b/include/noto/schema.hpp new file mode 100644 index 0000000..5aa3bf6 --- /dev/null +++ b/include/noto/schema.hpp @@ -0,0 +1,146 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace noto { + +class trusted_sql_fragment { +public: + trusted_sql_fragment() = default; + static auto unsafe(std::string sql) -> std::expected; + auto value() const -> std::string const&; + auto empty() const -> std::bool_t; +private: + explicit trusted_sql_fragment(std::string sql); + std::string sql_{}; +}; + +class column_type { +public: + column_type() = default; + static auto integer() -> noto::column_type; + static auto bigint() -> noto::column_type; + static auto varchar(std::uint32_t length) -> std::expected; + static auto text() -> noto::column_type; + static auto blob() -> noto::column_type; + static auto real() -> noto::column_type; + static auto boolean() -> noto::column_type; + static auto timestamp() -> noto::column_type; + static auto decimal(std::uint32_t precision, std::uint32_t scale) -> std::expected; + static auto trusted(noto::trusted_sql_fragment fragment) -> noto::column_type; + auto sql() const -> std::string const&; + auto empty() const -> std::bool_t; +private: + explicit column_type(std::string sql); + std::string sql_{}; +}; + +class default_value { +public: + default_value() = default; + static auto null_value() -> noto::default_value; + static auto string(std::string_view value) -> std::expected; + static auto integer(std::int64_t value) -> noto::default_value; + static auto unsigned_integer(std::uint64_t value) -> noto::default_value; + static auto real(std::float64_t value) -> std::expected; + static auto boolean(std::bool_t value) -> noto::default_value; + static auto current_timestamp() -> noto::default_value; + static auto trusted_expression(noto::trusted_sql_fragment fragment) -> noto::default_value; + auto sql() const -> std::string const&; + auto empty() const -> std::bool_t; +private: + explicit default_value(std::string sql); + std::string sql_{}; +}; + +struct table_name { + std::string database{}; + std::string name{}; +}; + +enum class generated_column_storage { + VIRTUAL, + STORED, +}; + +enum class reference_action { + RESTRICT, + CASCADE, + SET_NULL, + NO_ACTION, + SET_DEFAULT, +}; + +struct generated_column_definition { + noto::trusted_sql_fragment expression{}; + noto::generated_column_storage storage{noto::generated_column_storage::VIRTUAL}; +}; + +struct column_definition { + std::string name{}; + noto::column_type type{}; + std::bool_t nullable{true}; + std::bool_t auto_increment{false}; + std::bool_t primary_key{false}; + std::optional default_value{}; + std::optional generated{}; +}; + +struct unique_constraint_definition { + std::string name{}; + std::vector columns{}; +}; + +struct check_constraint_definition { + std::string name{}; + noto::trusted_sql_fragment expression{}; + std::bool_t enforced{true}; +}; + +struct foreign_key_definition { + std::string name{}; + std::vector columns{}; + noto::table_name referenced_table{}; + std::vector referenced_columns{}; + std::optional on_delete{}; + std::optional on_update{}; +}; + +struct table_options { + std::bool_t strict{false}; + std::bool_t without_rowid{false}; +}; + +struct table_definition { + noto::table_name name{}; + std::vector columns{}; + std::vector primary_key{}; + std::vector unique_constraints{}; + std::vector check_constraints{}; + std::vector foreign_keys{}; + noto::table_options options{}; + std::bool_t if_not_exists{true}; +}; + +struct index_definition { + noto::table_name table{}; + std::string name{}; + std::vector columns{}; + std::bool_t unique{false}; +}; + +auto quote_identifier(std::string_view identifier) -> std::expected; +auto create_table_sql(noto::table_definition const& definition) -> std::expected; +auto drop_table_sql(noto::table_name const& table, std::bool_t if_exists = true) -> std::expected; +auto create_index_sql(noto::index_definition const& definition) -> std::expected; +auto drop_index_sql(noto::index_definition const& definition) -> std::expected; +auto table_exists_sql() -> std::string; + +} diff --git a/include/noto/transaction.hpp b/include/noto/transaction.hpp new file mode 100644 index 0000000..a293817 --- /dev/null +++ b/include/noto/transaction.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace noto { + +namespace detail { + +struct connection_core; +struct transaction_state; + +} + +enum class transaction_mode { + DEFERRED, + IMMEDIATE, + EXCLUSIVE, +}; + +class connection; + +class transaction { +public: + transaction() = default; + transaction(noto::transaction const&) = delete; + transaction(noto::transaction&& other) noexcept; + ~transaction(); + auto operator=(noto::transaction const&) -> noto::transaction& = delete; + auto operator=(noto::transaction&& other) noexcept -> noto::transaction& = delete; + auto active() const -> std::bool_t; + auto async_commit() -> boost::asio::awaitable>; + auto async_rollback() -> boost::asio::awaitable>; +private: + friend class noto::connection; + friend struct noto::detail::connection_core; + explicit transaction(std::shared_ptr state); + explicit transaction(std::shared_ptr core, std::function on_finish = {}); + std::shared_ptr state_{}; +}; + +} diff --git a/modulus.json b/modulus.json new file mode 100644 index 0000000..02dc40e --- /dev/null +++ b/modulus.json @@ -0,0 +1,11 @@ +{ + "package": { + "name": "noto" + }, + "tooling": { + "remote": { + "name": "khodo", + "url": "https://git.thyme-labs.com/api/packages/khodo/conan" + } + } +} diff --git a/modulus.py b/modulus.py new file mode 100755 index 0000000..836b640 --- /dev/null +++ b/modulus.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +import json +import os +import pathlib +import shlex +import subprocess +import sys + +PACKAGE_REF = "modulus/latest" +CONAN_VERSION = "2.26.2" +FETCH_IMAGE = "modulus-fetch:dev" +COMMANDS = ( + "configure", + "build", + "test", + "package", + "lsp", +) +COMMON_DIR = pathlib.Path("build") / "build_common" +TOOLS_DIR = pathlib.Path("build") / "build_tools" +RUNTIME_DIR = COMMON_DIR / "modulus" +CONAN_HOME_DIR = COMMON_DIR / "conan_cache" +DEFAULT_REMOTE_NAME = "khodo" +DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan" +DEFAULT_DOCKER_MARKER = "/opt/modulus-build-image" + +def repo_root(): + root = pathlib.Path(__file__).resolve().parent + if not (root / "modulus.json").is_file(): + raise SystemExit("modulus.py must be run from a Modulus-managed repository root") + return root + +def read_json(path): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise SystemExit(f"failed to read {path}: {error}") from error + if not isinstance(data, dict): + raise SystemExit(f"{path} must contain a JSON object") + return data + +def remote_config(root): + config = read_json(root / "modulus.json") + remote = config.get( + "tooling", + {}, + ).get("remote", {}) + if not isinstance(remote, dict): + remote = {} + return ( + str(remote.get("name") or DEFAULT_REMOTE_NAME), + str(remote.get("url") or DEFAULT_REMOTE_URL), + ) + +def runtime_ready(root): + return ( + (root / TOOLS_DIR / "configure.py").is_file() + and (root / TOOLS_DIR / "lsp.py").is_file() + and (root / RUNTIME_DIR / "modulus" / "cli.py").is_file() + and (root / RUNTIME_DIR / "modulus" / "export.py").is_file() + ) + +def optional_credentials(): + user = os.environ.get("CONAN_USER") + token = os.environ.get("CONAN_TOKEN") + if bool(user) != bool(token): + raise SystemExit("CONAN_USER and CONAN_TOKEN must either both be set or both be unset") + return user, token + +def env_flag(name): + return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} + +def docker_marker(root): + config = read_json(root / "modulus.json") + marker = config.get( + "tooling", + {}, + ).get("docker", {}).get("marker", DEFAULT_DOCKER_MARKER) + return pathlib.Path(str(marker)) + +def direct_mode(root): + return env_flag("MODULUS_DIRECT") or docker_marker(root).exists() + +def run(command, input_text=None, capture=False, display_command=None, env=None, check=True): + visible = display_command if display_command is not None else command + print("+", " ".join(shlex.quote(str(part)) for part in visible)) + completed = subprocess.run( + [str(part) for part in command], + input=input_text, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + text=True, + check=False, + env=env, + ) + if check and completed.returncode != 0: + if capture and completed.stdout: + print(completed.stdout, end="") + if capture and completed.stderr: + print(completed.stderr, end="", file=sys.stderr) + raise SystemExit(completed.returncode) + return completed + +def fetch_dockerfile(): + return f"""FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \\ + && apt-get install -y --no-install-recommends \\ + ca-certificates \\ + python3 \\ + python3-venv \\ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m venv /opt/modulus-python + +ENV PATH=/opt/modulus-python/bin:$PATH + +RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION} + +WORKDIR /workspace +""" + +def ensure_fetch_image(): + inspected = subprocess.run( + [ + "docker", + "image", + "inspect", + FETCH_IMAGE, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if inspected.returncode == 0: + return + run( + [ + "docker", + "build", + "--tag", + FETCH_IMAGE, + "-", + ], + input_text=fetch_dockerfile(), + ) + +def in_container(): + return pathlib.Path("/.dockerenv").exists() + +def docker_workspace_args(root): + if in_container(): + container = os.environ.get("HOSTNAME", "").strip() + if not container: + raise SystemExit("HOSTNAME is required to share the CI workspace with the Modulus fetch container") + return [ + "--volumes-from", + container, + ] + return [ + "--volume", + f"{root}:{root}", + ] + +def fetch_env(root): + env = os.environ.copy() + env["CONAN_HOME"] = str(root / CONAN_HOME_DIR) + return env + +def run_fetch(root, command, capture=False, display_command=None, check=True): + if direct_mode(root): + return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) + ensure_fetch_image() + docker_command = [ + "docker", + "run", + "--rm", + "--interactive", + "--user", + f"{os.getuid()}:{os.getgid()}", + "--env", + "HOME=/tmp", + "--env", + "CONAN_HOME", + "--env", + "CONAN_USER", + "--env", + "CONAN_TOKEN", + *docker_workspace_args(root), + "--workdir", + str(root), + FETCH_IMAGE, + *command, + ] + return run(docker_command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) + +def configure_remote(root, remote_name, remote_url): + user, token = optional_credentials() + run_fetch( + root, + [ + "conan", + "remote", + "add", + "-f", + remote_name, + remote_url, + ], + ) + if not user and not token: + return + run_fetch( + root, + [ + "conan", + "remote", + "login", + remote_name, + user, + "-p", + token, + ], + display_command=[ + "conan", + "remote", + "login", + remote_name, + user, + "-p", + "", + ], + ) + +def package_refs(data): + refs = [] + for location_data in data.values(): + if not isinstance(location_data, dict): + continue + for ref, ref_data in location_data.items(): + if not ref.startswith(PACKAGE_REF) or not isinstance(ref_data, dict): + continue + revisions = ref_data.get( + "revisions", + {}, + ) + if not isinstance(revisions, dict): + continue + for recipe_revision, revision_data in revisions.items(): + if not isinstance(revision_data, dict): + continue + packages = revision_data.get( + "packages", + {}, + ) + if not isinstance(packages, dict): + continue + for package_id, package_data in packages.items(): + package_revisions = package_data.get( + "revisions", + {}, + )if isinstance(package_data, dict) else {} + if isinstance(package_revisions, dict) and package_revisions: + for package_revision in package_revisions: + refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}#{package_revision}") + else: + refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}") + return sorted(refs) + +def remove_cached_modulus(root): + run_fetch( + root, + [ + "conan", + "remove", + PACKAGE_REF + ":*", + "-c", + ], + check=False, + ) + +def download_modulus(root, remote_name): + remove_cached_modulus(root) + run_fetch( + root, + [ + "conan", + "download", + PACKAGE_REF + ":*", + "-r", + remote_name, + ], + ) + completed = run_fetch( + root, + [ + "conan", + "list", + PACKAGE_REF + ":*", + "-c", + "-f", + "json", + ], + capture=True, + ) + refs = package_refs(json.loads(completed.stdout)) + if not refs: + raise SystemExit(f"no downloaded {PACKAGE_REF} package found in the Conan cache") + package_ref = refs[-1] + completed = run_fetch( + root, + [ + "conan", + "cache", + "path", + package_ref, + ], + capture=True, + ) + package_root = pathlib.Path(completed.stdout.strip()) + if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir(): + raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}") + return package_root + +def materialize_runtime(root): + remote_name, remote_url = remote_config(root) + configure_remote(root, remote_name, remote_url) + package_root = download_modulus(root, remote_name) + sys.path.insert(0, str(package_root)) + import modulus.export + modulus.export.sync_project(root, package_root) + +def ensure_runtime(root): + if runtime_ready(root): + return + materialize_runtime(root) + +def delegate(command, argv): + root = repo_root() + ensure_runtime(root) + sys.path.insert(0, str(root / RUNTIME_DIR)) + import modulus.cli + previous_argv = sys.argv + previous_cwd = pathlib.Path.cwd() + sys.argv = [ + str(root / "modulus.py"), + *argv, + ] + os.chdir(root) + try: + return modulus.cli.main(command) + finally: + os.chdir(previous_cwd) + sys.argv = previous_argv + +def main(argv): + if not argv or argv[0] in ("-h", "--help"): + print("usage: python3 modulus.py " + "|".join(COMMANDS) + " ...") + return 0 if argv else 2 + command = argv[0] + if command not in COMMANDS: + raise SystemExit(f"unknown Modulus command: {command}") + return delegate(command, argv[1:]) + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..f8c1c11 --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,361 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include + +namespace { + +constexpr auto json_options = modulus::json_config::options{ + .category = "noto", + .invalid_argument_code = noto::detail::error_invalid_argument, + .not_found_code = noto::detail::error_not_found +}; + +auto invalid(std::string message) -> std::expected { + return noto::detail::fail_void(noto::detail::error_invalid_argument, std::move(message)); +} + +template +auto invalid_result(std::string message) -> std::expected { + return noto::detail::fail(noto::detail::error_invalid_argument, std::move(message)); +} + +auto contains_null(std::string_view value) -> std::bool_t { + return value.find('\0') != std::string_view::npos; +} + +auto sqlite_int_max() -> std::uint64_t { + return static_cast(std::numeric_limits::max()); +} + +auto valid_pragma_name(std::string_view value) -> std::bool_t { + if (value.empty() || contains_null(value)) { + return false; + } + for (auto character : value) { + auto byte = static_cast(character); + if ((byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z') || (byte >= '0' && byte <= '9') || character == '_') { + continue; + } + return false; + } + return true; +} + +auto mode_name(noto::open_mode mode) -> std::string_view { + switch (mode) { + case noto::open_mode::READ_ONLY: + return "read_only"; + case noto::open_mode::READ_WRITE: + return "read_write"; + case noto::open_mode::READ_WRITE_CREATE: + return "read_write_create"; + case noto::open_mode::MEMORY: + return "memory"; + } + return "read_write_create"; +} + +auto required_string_field(boost::json::object const& object, std::string_view name) -> std::expected { + auto value = modulus::json_config::required_string_field(object, name, "database", json_options); + if (!value) { + return noto::detail::fail(value.error()); + } + if (contains_null(*value)) { + return invalid_result(std::string{"database "} + std::string{name} + " must not contain null bytes"); + } + return std::move(*value); +} + +auto optional_string_field(boost::json::object const& object, std::string_view name) -> std::expected, modulus::error> { + auto found = object.find(name); + if (found == object.end()) { + return std::optional{}; + } + auto parsed = modulus::json_config::optional_string_field(object, name, {}, json_options); + if (!parsed) { + return noto::detail::fail>(parsed.error()); + } + auto value = std::move(*parsed); + if (value.empty()) { + return invalid_result>(std::string{"database "} + std::string{name} + " must not be empty"); + } + if (contains_null(value)) { + return invalid_result>(std::string{"database "} + std::string{name} + " must not contain null bytes"); + } + return std::optional{std::move(value)}; +} + +auto parse_pragma_value(boost::json::value const& value, std::string_view name) -> std::expected, modulus::error> { + if (value.is_bool()) { + return std::variant{value.as_bool()}; + } + if (value.is_string()) { + return std::variant{ + std::string{value.as_string()}, + }; + } + if (value.is_int64()) { + return std::variant{value.as_int64()}; + } + if (value.is_uint64()) { + if (value.as_uint64() > static_cast(std::numeric_limits::max())) { + return invalid_result>(std::string{name} + " pragma value is out of range"); + } + return std::variant{static_cast(value.as_uint64())}; + } + return invalid_result>(std::string{name} + " pragma value must be a string, integer, or boolean"); +} + +auto parse_pragmas(boost::json::object const& object) -> std::expected, modulus::error> { + auto found = object.find("pragmas"); + if (found == object.end()) { + return std::vector{}; + } + auto pragma_object = modulus::json_config::require_object(found->value(), "database pragmas", json_options); + if (!pragma_object) { + return noto::detail::fail>(pragma_object.error()); + } + auto pragmas = std::vector{}; + for (auto const& item : **pragma_object) { + auto name = std::string{item.key()}; + if (!valid_pragma_name(name)) { + return invalid_result>("database pragma names must contain only letters, digits, or underscores"); + } + auto value = parse_pragma_value(item.value(), name); + if (!value) { + return noto::detail::fail>(value.error()); + } + pragmas.push_back({ + .name = std::move(name), + .value = std::move(*value), + }); + } + return pragmas; +} + +} + +auto noto::validate(noto::connection_config const& config) -> std::expected { + if (config.filename.empty()) { + return invalid("database filename must not be empty"); + } + if (contains_null(config.filename)) { + return invalid("database filename must not contain null bytes"); + } + if (contains_null(config.name)) { + return invalid("database name must not contain null bytes"); + } + if (config.busy_timeout.count() <= 0) { + return invalid("database busy timeout must be positive"); + } + if (static_cast(config.busy_timeout.count()) > sqlite_int_max()) { + return invalid("database busy timeout is out of range"); + } + for (auto const& pragma : config.pragmas) { + if (!valid_pragma_name(pragma.name)) { + return invalid("database pragma names must contain only letters, digits, or underscores"); + } + } + return {}; +} + +auto noto::memory_connection_config(std::string name) -> noto::connection_config { + return { + .name = std::move(name), + .filename = ":memory:", + .mode = noto::open_mode::MEMORY, + }; +} + +auto noto::file_connection_config(std::string filename, std::string name) -> noto::connection_config { + return { + .name = std::move(name), + .filename = std::move(filename), + }; +} + +auto noto::parse_database_config(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "database", json_options); + if (!object) { + return noto::detail::fail(object.error()); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, { + "name", + "filename", + "mode", + "uri", + "busy_timeout_ms", + "foreign_keys", + "pragmas", + }, "database", json_options); + if (!unknown) { + return noto::detail::fail(unknown.error()); + } + auto name = required_string_field(**object, "name"); + if (!name) { + return noto::detail::fail(name.error()); + } + auto mode = modulus::json_config::optional_enum_field(**object, "mode", noto::open_mode::READ_WRITE_CREATE, { + { + .name = "read_only", + .value = noto::open_mode::READ_ONLY, + }, + { + .name = "read_write", + .value = noto::open_mode::READ_WRITE, + }, + { + .name = "read_write_create", + .value = noto::open_mode::READ_WRITE_CREATE, + }, + { + .name = "memory", + .value = noto::open_mode::MEMORY, + }, + }, "database mode must be read_only, read_write, read_write_create, or memory", json_options); + if (!mode) { + return noto::detail::fail(mode.error()); + } + auto filename = optional_string_field(**object, "filename"); + if (!filename) { + return noto::detail::fail(filename.error()); + } + if (!*filename && *mode != noto::open_mode::MEMORY) { + return noto::detail::fail(noto::detail::error_invalid_argument, "database filename is required"); + } + auto uri = modulus::json_config::optional_bool_field(**object, "uri", false, json_options); + if (!uri) { + return noto::detail::fail(uri.error()); + } + auto foreign_keys = modulus::json_config::optional_bool_field(**object, "foreign_keys", true, json_options); + if (!foreign_keys) { + return noto::detail::fail(foreign_keys.error()); + } + auto timeout = modulus::json_config::positive_milliseconds_field(**object, "busy_timeout_ms", std::chrono::milliseconds{5000}, sqlite_int_max(), json_options); + if (!timeout) { + return noto::detail::fail(timeout.error()); + } + auto pragmas = parse_pragmas(**object); + if (!pragmas) { + return noto::detail::fail(pragmas.error()); + } + auto config = noto::connection_config{ + .name = std::move(*name), + .filename = *filename ? std::move(**filename) : std::string{":memory:"}, + .mode = *mode, + .uri = *uri, + .busy_timeout = *timeout, + .foreign_keys = *foreign_keys, + .pragmas = std::move(*pragmas), + }; + auto valid = noto::validate(config); + if (!valid) { + return std::unexpected(valid.error()); + } + return config; +} + +auto noto::parse_database_configs(boost::json::value const& value) -> std::expected, modulus::error> { + auto object = modulus::json_config::require_object(value, "config root", json_options); + if (!object) { + return noto::detail::fail>(object.error()); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, {"databases"}, "config root", json_options); + if (!unknown) { + return noto::detail::fail>(unknown.error()); + } + auto found = (*object)->find("databases"); + if (found == (*object)->end() || !found->value().is_array()) { + return invalid_result>("config databases array is required"); + } + auto configs = std::vector{}; + for (auto const& database_value : found->value().as_array()) { + auto parsed = noto::parse_database_config(database_value); + if (!parsed) { + return noto::detail::fail>(parsed.error()); + } + configs.push_back(std::move(*parsed)); + } + return configs; +} + +auto noto::load_database_configs(std::string const& path) -> std::expected, modulus::error> { + auto value = modulus::json_config::load_value(path, json_options); + if (!value) { + return noto::detail::fail>(value.error()); + } + return noto::parse_database_configs(*value); +} + +auto noto::to_json(std::variant const& value) -> boost::json::value { + return std::visit([](auto const& item) -> boost::json::value { + return boost::json::value{item}; + }, value); +} + +auto noto::to_json(noto::connection_config const& config) -> boost::json::value { + auto pragmas = boost::json::object{}; + for (auto const& pragma : config.pragmas) { + pragmas[pragma.name] = noto::to_json(pragma.value); + } + return boost::json::object{ + { + "name", + config.name, + }, + { + "filename", + config.filename, + }, + { + "mode", + std::string{mode_name(config.mode)}, + }, + { + "uri", + config.uri, + }, + { + "busy_timeout_ms", + config.busy_timeout.count(), + }, + { + "foreign_keys", + config.foreign_keys, + }, + { + "pragmas", + std::move(pragmas), + }, + }; +} + +auto noto::to_json(std::vector const& configs) -> boost::json::value { + auto databases = boost::json::array{}; + for (auto const& config : configs) { + databases.push_back(noto::to_json(config)); + } + return boost::json::object{ + { + "databases", + std::move(databases), + }, + }; +} diff --git a/src/connection.cpp b/src/connection.cpp new file mode 100644 index 0000000..9d957b3 --- /dev/null +++ b/src/connection.cpp @@ -0,0 +1,593 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +auto invalid_connection() -> std::expected { + return noto::detail::fail_void(noto::detail::error_invalid_argument, "database connection is not valid"); +} + +auto invalid_statement() -> std::expected { + return noto::detail::fail_void(noto::detail::error_invalid_argument, "database statement is not valid"); +} + +auto invalid(std::string message) -> std::expected { + return noto::detail::fail_void(noto::detail::error_invalid_argument, std::move(message)); +} + +auto async_status(std::expected status) -> boost::asio::awaitable> { + co_return status; +} + +auto contains_null(std::string_view value) -> std::bool_t { + return value.find('\0') != std::string_view::npos; +} + +auto valid_token(std::string_view value) -> std::bool_t { + if (value.empty() || contains_null(value)) { + return false; + } + for (auto character : value) { + auto byte = static_cast(character); + if ((byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z') || (byte >= '0' && byte <= '9') || character == '_') { + continue; + } + return false; + } + return true; +} + +auto pragma_value_sql(std::variant const& value) -> std::expected { + return std::visit([](auto const& current) -> std::expected { + if constexpr (std::is_same_v, std::bool_t>) { + return std::string{current ? "1" : "0"}; + } else if constexpr (std::is_same_v, std::int64_t>) { + return std::to_string(current); + } else { + return noto::detail::quote_sql_literal(current, "pragma value"); + } + }, value); +} + +auto first_int(noto::query_result const& result) -> std::expected { + if (result.rows.empty() || result.rows.front().empty()) { + return noto::detail::fail(noto::detail::error_not_found, "query returned no rows"); + } + auto const& value = result.rows.front().front().value; + if (auto integer = std::get_if(&value)) { + return *integer; + } + return noto::detail::fail(noto::detail::error_invalid_argument, "query did not return an integer"); +} + +auto first_string(noto::query_result const& result) -> std::expected { + if (result.rows.empty() || result.rows.front().empty()) { + return noto::detail::fail(noto::detail::error_not_found, "query returned no rows"); + } + auto const& value = result.rows.front().front().value; + if (auto text = std::get_if(&value)) { + return *text; + } + if (auto integer = std::get_if(&value)) { + return std::to_string(*integer); + } + return noto::detail::fail(noto::detail::error_invalid_argument, "query did not return text"); +} + +auto async_statement_reset(std::shared_ptr core, std::vector params) -> boost::asio::awaitable> { + if (!core) { + co_return invalid_statement(); + } + co_return co_await core->async_reset(std::move(params)); +} + +auto async_statement_step(std::shared_ptr core) -> boost::asio::awaitable>, modulus::error>> { + if (!core) { + co_return std::unexpected(invalid_statement().error()); + } + co_return co_await core->async_step(); +} + +auto async_statement_execute(std::shared_ptr core, std::vector params, noto::execute_options options) -> boost::asio::awaitable> { + if (!core) { + co_return std::unexpected(invalid_statement().error()); + } + co_return co_await core->async_execute(std::move(params), std::move(options)); +} + +auto async_statement_finalize(std::shared_ptr core) -> boost::asio::awaitable> { + if (!core) { + co_return invalid_statement(); + } + co_return co_await core->async_finalize(); +} + +auto async_connection_connect(std::shared_ptr core) -> boost::asio::awaitable> { + if (!core) { + co_return invalid_connection(); + } + co_return co_await core->async_connect(); +} + +auto async_connection_close(std::shared_ptr core) -> boost::asio::awaitable> { + if (!core) { + co_return invalid_connection(); + } + co_return co_await core->async_close(); +} + +auto async_connection_execute(std::shared_ptr core, std::string sql, std::vector params, noto::execute_options options) -> boost::asio::awaitable> { + if (!core) { + co_return std::unexpected(invalid_connection().error()); + } + co_return co_await core->async_execute(std::move(sql), std::move(params), std::move(options)); +} + +auto async_connection_prepare(std::shared_ptr core, std::string sql) -> boost::asio::awaitable> { + if (!core) { + co_return std::unexpected(invalid_connection().error()); + } + co_return co_await core->async_prepare(std::move(sql)); +} + +auto async_connection_begin_transaction(std::shared_ptr core, noto::transaction_mode mode) -> boost::asio::awaitable> { + if (!core) { + co_return std::unexpected(invalid_connection().error()); + } + co_return co_await core->async_begin_transaction(mode); +} + +auto async_connection_backup_to_file(std::shared_ptr core, std::string path, noto::backup_options options) -> boost::asio::awaitable> { + if (!core) { + co_return invalid_connection(); + } + co_return co_await core->async_backup_to_file(std::move(path), std::move(options)); +} + +auto async_connection_restore_from_file(std::shared_ptr core, std::string path, noto::backup_options options) -> boost::asio::awaitable> { + if (!core) { + co_return invalid_connection(); + } + co_return co_await core->async_restore_from_file(std::move(path), std::move(options)); +} + +auto async_connection_export_sql(std::shared_ptr core, noto::export_options options) -> boost::asio::awaitable> { + if (!core) { + co_return std::unexpected(invalid_connection().error()); + } + co_return co_await core->async_export_sql(std::move(options)); +} + +auto async_connection_export_sql_to_file(std::shared_ptr core, std::string path, noto::export_options options) -> boost::asio::awaitable> { + if (!core) { + co_return invalid_connection(); + } + co_return co_await core->async_export_sql_to_file(std::move(path), std::move(options)); +} + +auto async_connection_create_table(noto::connection connection, noto::table_definition definition) -> boost::asio::awaitable> { + auto sql = noto::create_table_sql(definition); + if (!sql) { + co_return std::unexpected(sql.error()); + } + auto result = co_await connection.async_execute(*sql); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return std::expected{}; +} + +auto async_connection_drop_table(noto::connection connection, noto::table_name table, std::bool_t if_exists) -> boost::asio::awaitable> { + auto sql = noto::drop_table_sql(table, if_exists); + if (!sql) { + co_return std::unexpected(sql.error()); + } + auto result = co_await connection.async_execute(*sql); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return std::expected{}; +} + +auto async_connection_create_index(noto::connection connection, noto::index_definition definition) -> boost::asio::awaitable> { + auto sql = noto::create_index_sql(definition); + if (!sql) { + co_return std::unexpected(sql.error()); + } + auto result = co_await connection.async_execute(*sql); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return std::expected{}; +} + +auto async_connection_drop_index(noto::connection connection, noto::index_definition definition) -> boost::asio::awaitable> { + auto sql = noto::drop_index_sql(definition); + if (!sql) { + co_return std::unexpected(sql.error()); + } + auto result = co_await connection.async_execute(*sql); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return std::expected{}; +} + +auto async_connection_integrity_check(noto::connection connection) -> boost::asio::awaitable, modulus::error>> { + auto result = co_await connection.async_execute("PRAGMA integrity_check"); + if (!result) { + co_return std::unexpected(result.error()); + } + auto messages = std::vector{}; + for (auto const& row : result->rows) { + if (!row.empty()) { + if (auto text = std::get_if(&row.front().value)) { + messages.push_back(*text); + } + } + } + co_return messages; +} + +auto async_connection_quick_check(noto::connection connection) -> boost::asio::awaitable, modulus::error>> { + auto result = co_await connection.async_execute("PRAGMA quick_check"); + if (!result) { + co_return std::unexpected(result.error()); + } + auto messages = std::vector{}; + for (auto const& row : result->rows) { + if (!row.empty()) { + if (auto text = std::get_if(&row.front().value)) { + messages.push_back(*text); + } + } + } + co_return messages; +} + +auto async_connection_foreign_key_check(noto::connection connection) -> boost::asio::awaitable> { + co_return co_await connection.async_execute("PRAGMA foreign_key_check"); +} + +auto async_connection_get_pragma(noto::connection connection, std::string name) -> boost::asio::awaitable> { + if (!valid_token(name)) { + co_return std::unexpected(invalid("database pragma names must contain only letters, digits, or underscores").error()); + } + co_return co_await connection.async_execute("PRAGMA " + name); +} + +auto async_connection_set_pragma(noto::connection connection, std::string name, std::variant value) -> boost::asio::awaitable> { + if (!valid_token(name)) { + co_return invalid("database pragma names must contain only letters, digits, or underscores"); + } + auto rendered = pragma_value_sql(value); + if (!rendered) { + co_return std::unexpected(rendered.error()); + } + auto result = co_await connection.async_execute("PRAGMA " + name + " = " + *rendered); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return std::expected{}; +} + +auto async_connection_foreign_keys(noto::connection connection) -> boost::asio::awaitable> { + auto result = co_await async_connection_get_pragma(std::move(connection), "foreign_keys"); + if (!result) { + co_return std::unexpected(result.error()); + } + auto integer = first_int(*result); + if (!integer) { + co_return std::unexpected(integer.error()); + } + co_return *integer != 0; +} + +auto async_connection_set_foreign_keys(noto::connection connection, std::bool_t enabled) -> boost::asio::awaitable> { + co_return co_await async_connection_set_pragma(std::move(connection), "foreign_keys", enabled); +} + +auto async_connection_busy_timeout(noto::connection connection) -> boost::asio::awaitable> { + auto result = co_await async_connection_get_pragma(std::move(connection), "busy_timeout"); + if (!result) { + co_return std::unexpected(result.error()); + } + auto integer = first_int(*result); + if (!integer) { + co_return std::unexpected(integer.error()); + } + co_return std::chrono::milliseconds{*integer}; +} + +auto async_connection_set_busy_timeout(std::shared_ptr core, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!core) { + co_return invalid_connection(); + } + co_return co_await core->async_set_busy_timeout(timeout); +} + +auto async_connection_user_version(noto::connection connection) -> boost::asio::awaitable> { + auto result = co_await async_connection_get_pragma(std::move(connection), "user_version"); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return first_int(*result); +} + +auto async_connection_set_user_version(noto::connection connection, std::int64_t version) -> boost::asio::awaitable> { + co_return co_await async_connection_set_pragma(std::move(connection), "user_version", version); +} + +auto async_connection_application_id(noto::connection connection) -> boost::asio::awaitable> { + auto result = co_await async_connection_get_pragma(std::move(connection), "application_id"); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return first_int(*result); +} + +auto async_connection_set_application_id(noto::connection connection, std::int64_t id) -> boost::asio::awaitable> { + co_return co_await async_connection_set_pragma(std::move(connection), "application_id", id); +} + +auto async_connection_journal_mode(noto::connection connection, std::string mode) -> boost::asio::awaitable> { + if (!valid_token(mode)) { + co_return std::unexpected(invalid("journal mode must contain only letters, digits, or underscores").error()); + } + auto result = co_await connection.async_execute("PRAGMA journal_mode = " + mode); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return first_string(*result); +} + +auto async_connection_wal_checkpoint(noto::connection connection, std::string mode) -> boost::asio::awaitable> { + if (!valid_token(mode)) { + co_return std::unexpected(invalid("WAL checkpoint mode must contain only letters, digits, or underscores").error()); + } + co_return co_await connection.async_execute("PRAGMA wal_checkpoint(" + mode + ")"); +} + +} + +noto::sqlite_value::sqlite_value(std::nullptr_t) : value{std::monostate{}} {} + +noto::sqlite_value::sqlite_value(std::int32_t value) : value{static_cast(value)} {} + +noto::sqlite_value::sqlite_value(std::int64_t current) : value{current} {} + +noto::sqlite_value::sqlite_value(std::float64_t current) : value{current} {} + +noto::sqlite_value::sqlite_value(std::char_t const* current) : value{std::string{current == nullptr ? "" : current}} {} + +noto::sqlite_value::sqlite_value(std::string current) : value{std::move(current)} {} + +noto::sqlite_value::sqlite_value(std::string_view current) : value{std::string{current}} {} + +noto::sqlite_value::sqlite_value(std::vector current) : value{std::move(current)} {} + +auto noto::sqlite_value::is_null() const -> std::bool_t { + return std::holds_alternative(value); +} + +noto::statement::~statement() = default; + +auto noto::statement::valid() const -> std::bool_t { + return core_ && !core_->finalized.load(std::memory_order_acquire); +} + +auto noto::statement::async_reset(std::vector params) -> boost::asio::awaitable> { + return async_statement_reset(core_, std::move(params)); +} + +auto noto::statement::async_step() -> boost::asio::awaitable>, modulus::error>> { + return async_statement_step(core_); +} + +auto noto::statement::async_execute(std::vector params) -> boost::asio::awaitable> { + return async_execute(std::move(params), noto::execute_options{}); +} + +auto noto::statement::async_execute(std::vector params, noto::execute_options options) -> boost::asio::awaitable> { + return async_statement_execute(core_, std::move(params), std::move(options)); +} + +auto noto::statement::async_finalize() -> boost::asio::awaitable> { + auto core = core_; + if (!core) { + return async_status(invalid_statement()); + } + return async_statement_finalize(std::move(core)); +} + +noto::statement::statement(std::shared_ptr core) : core_{std::move(core)} {} + +noto::connection::connection(boost::asio::any_io_executor executor, noto::connection_config config) : core_{std::make_shared(std::move(executor), std::move(config))} {} + +noto::connection::~connection() = default; + +auto noto::connection::config() const -> noto::connection_config { + return core_ ? core_->config : noto::connection_config{}; +} + +auto noto::connection::state() const -> noto::connection_state { + return core_ ? core_->current_state() : noto::connection_state::CLOSED; +} + +auto noto::connection::async_connect() -> boost::asio::awaitable> { + assert(core_ != nullptr); + modulus::log_debug("noto connection opening"); + return async_connection_connect(core_); +} + +auto noto::connection::async_close() -> boost::asio::awaitable> { + assert(core_ != nullptr); + modulus::log_debug("noto connection closing"); + return async_connection_close(core_); +} + +auto noto::connection::async_execute(std::string sql, std::vector params) -> boost::asio::awaitable> { + return async_execute(std::move(sql), std::move(params), noto::execute_options{}); +} + +auto noto::connection::async_execute(std::string sql, std::vector params, noto::execute_options options) -> boost::asio::awaitable> { + assert(core_ != nullptr); + modulus::log_trace("noto executing SQL statement"); + return async_connection_execute(core_, std::move(sql), std::move(params), std::move(options)); +} + +auto noto::connection::async_prepare(std::string sql) -> boost::asio::awaitable> { + return async_connection_prepare(core_, std::move(sql)); +} + +auto noto::connection::async_begin_transaction(noto::transaction_mode mode) -> boost::asio::awaitable> { + assert(core_ != nullptr); + modulus::log_debug("noto transaction begin requested"); + return async_connection_begin_transaction(core_, mode); +} + +auto noto::connection::async_create_table(noto::table_definition definition) -> boost::asio::awaitable> { + return async_connection_create_table(noto::connection{core_}, std::move(definition)); +} + +auto noto::connection::async_drop_table(noto::table_name table, std::bool_t if_exists) -> boost::asio::awaitable> { + return async_connection_drop_table(noto::connection{core_}, std::move(table), if_exists); +} + +auto noto::connection::async_create_index(noto::index_definition definition) -> boost::asio::awaitable> { + return async_connection_create_index(noto::connection{core_}, std::move(definition)); +} + +auto noto::connection::async_drop_index(noto::index_definition definition) -> boost::asio::awaitable> { + return async_connection_drop_index(noto::connection{core_}, std::move(definition)); +} + +auto noto::connection::async_backup_to_file(std::string path) -> boost::asio::awaitable> { + auto options = noto::backup_options{}; + return async_backup_to_file(std::move(path), std::move(options)); +} + +auto noto::connection::async_backup_to_file(std::string path, noto::backup_options options) -> boost::asio::awaitable> { + return async_connection_backup_to_file(core_, std::move(path), std::move(options)); +} + +auto noto::connection::async_restore_from_file(std::string path) -> boost::asio::awaitable> { + auto options = noto::backup_options{}; + return async_restore_from_file(std::move(path), std::move(options)); +} + +auto noto::connection::async_restore_from_file(std::string path, noto::backup_options options) -> boost::asio::awaitable> { + return async_connection_restore_from_file(core_, std::move(path), std::move(options)); +} + +auto noto::connection::async_export_sql() -> boost::asio::awaitable> { + return async_export_sql(noto::export_options{}); +} + +auto noto::connection::async_export_sql(noto::export_options options) -> boost::asio::awaitable> { + return async_connection_export_sql(core_, std::move(options)); +} + +auto noto::connection::async_export_sql_to_file(std::string path) -> boost::asio::awaitable> { + auto options = noto::export_options{}; + return async_export_sql_to_file(std::move(path), std::move(options)); +} + +auto noto::connection::async_export_sql_to_file(std::string path, noto::export_options options) -> boost::asio::awaitable> { + return async_connection_export_sql_to_file(core_, std::move(path), std::move(options)); +} + +auto noto::connection::async_integrity_check() -> boost::asio::awaitable, modulus::error>> { + return async_connection_integrity_check(noto::connection{core_}); +} + +auto noto::connection::async_quick_check() -> boost::asio::awaitable, modulus::error>> { + return async_connection_quick_check(noto::connection{core_}); +} + +auto noto::connection::async_foreign_key_check() -> boost::asio::awaitable> { + return async_connection_foreign_key_check(noto::connection{core_}); +} + +auto noto::connection::async_get_pragma(std::string name) -> boost::asio::awaitable> { + return async_connection_get_pragma(noto::connection{core_}, std::move(name)); +} + +auto noto::connection::async_set_pragma(std::string name, std::variant value) -> boost::asio::awaitable> { + if (name == "busy_timeout") { + if (auto timeout = std::get_if(&value)) { + return async_connection_set_busy_timeout(core_, std::chrono::milliseconds{*timeout}); + } + } + return async_connection_set_pragma(noto::connection{core_}, std::move(name), std::move(value)); +} + +auto noto::connection::async_foreign_keys() -> boost::asio::awaitable> { + return async_connection_foreign_keys(noto::connection{core_}); +} + +auto noto::connection::async_set_foreign_keys(std::bool_t enabled) -> boost::asio::awaitable> { + return async_connection_set_foreign_keys(noto::connection{core_}, enabled); +} + +auto noto::connection::async_busy_timeout() -> boost::asio::awaitable> { + return async_connection_busy_timeout(noto::connection{core_}); +} + +auto noto::connection::async_set_busy_timeout(std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + return async_connection_set_busy_timeout(core_, timeout); +} + +auto noto::connection::async_user_version() -> boost::asio::awaitable> { + return async_connection_user_version(noto::connection{core_}); +} + +auto noto::connection::async_set_user_version(std::int64_t version) -> boost::asio::awaitable> { + return async_connection_set_user_version(noto::connection{core_}, version); +} + +auto noto::connection::async_application_id() -> boost::asio::awaitable> { + return async_connection_application_id(noto::connection{core_}); +} + +auto noto::connection::async_set_application_id(std::int64_t id) -> boost::asio::awaitable> { + return async_connection_set_application_id(noto::connection{core_}, id); +} + +auto noto::connection::async_journal_mode(std::string mode) -> boost::asio::awaitable> { + return async_connection_journal_mode(noto::connection{core_}, std::move(mode)); +} + +auto noto::connection::async_wal_checkpoint(std::string mode) -> boost::asio::awaitable> { + return async_connection_wal_checkpoint(noto::connection{core_}, std::move(mode)); +} + +noto::connection::connection(std::shared_ptr core) : core_{std::move(core)} {} + +auto noto::connection::core() const -> std::shared_ptr { + return core_; +} diff --git a/src/detail/core.cpp b/src/detail/core.cpp new file mode 100644 index 0000000..893430a --- /dev/null +++ b/src/detail/core.cpp @@ -0,0 +1,2024 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +struct pending_statement_finalize { + pending_statement_finalize(std::shared_ptr owner_value, sqlite3_stmt* statement_value, std::bool_t counted_value); + pending_statement_finalize(pending_statement_finalize const&) = delete; + auto operator=(pending_statement_finalize const&) -> pending_statement_finalize& = delete; + ~pending_statement_finalize(); + auto finish() noexcept -> void; + std::shared_ptr owner{}; + sqlite3_stmt* statement{}; + std::bool_t counted{}; +}; + +struct pending_transaction_rollback { + explicit pending_transaction_rollback(std::shared_ptr owner_value); + pending_transaction_rollback(pending_transaction_rollback const&) = delete; + auto operator=(pending_transaction_rollback const&) -> pending_transaction_rollback& = delete; + ~pending_transaction_rollback(); + auto finish() noexcept -> void; + std::shared_ptr owner{}; + std::bool_t active{true}; +}; + +struct file_identity { + dev_t device{}; + ino_t inode{}; +}; + +struct protected_path { + std::filesystem::path normalized{}; + std::optional identity{}; +}; + +struct export_column { + std::string quoted_name{}; +}; + +struct export_table { + std::string quoted_name{}; + std::vector columns{}; +}; + +struct export_schema { + std::vector tables{}; + std::vector table_sql{}; + std::vector post_data_sql{}; + std::bool_t has_sqlite_sequence{}; +}; + +auto invalid_statement() -> std::expected { + return noto::detail::fail_void(noto::detail::error_invalid_argument, "database statement is not valid"); +} + +auto unavailable(std::string message) -> std::expected { + return noto::detail::fail_void(noto::detail::error_unavailable, std::move(message)); +} + +auto invalid(std::string message) -> std::expected { + return noto::detail::fail_void(noto::detail::error_invalid_argument, std::move(message)); +} + +auto timeout(std::string message) -> std::expected { + return noto::detail::fail_void(noto::detail::error_timeout, std::move(message)); +} + +auto resource_exhausted(std::string message) -> std::expected { + return noto::detail::fail_void(noto::detail::error_resource_exhausted, std::move(message)); +} + +template +auto failure_from(modulus::error error) -> std::expected { + return noto::detail::fail(std::move(error)); +} + +template +auto failure_from(std::expected status) -> std::expected { + return noto::detail::fail(status); +} + +auto contains_null(std::string_view value) -> std::bool_t { + return value.find('\0') != std::string_view::npos; +} + +constexpr auto sqlite_sidecar_suffixes = std::array{"-wal", "-shm", "-journal"}; + +auto sqlite_int_max() -> std::size_t { + return static_cast(std::numeric_limits::max()); +} + +auto validate_sqlite_milliseconds(std::chrono::milliseconds value, std::string_view label) -> std::expected { + if (value.count() <= 0) { + return invalid(std::string{label} + " must be positive"); + } + if (static_cast(value.count()) > static_cast(std::numeric_limits::max())) { + return invalid(std::string{label} + " is out of range"); + } + return {}; +} + +auto validate_path(std::string_view path, std::string_view label) -> std::expected { + if (path.empty()) { + return invalid(std::string{label} + " path must not be empty"); + } + if (contains_null(path)) { + return invalid(std::string{label} + " path must not contain null bytes"); + } + return {}; +} + +auto errno_status(std::string operation, std::int32_t code) -> std::expected { + return noto::detail::fail_void(noto::detail::error_unavailable, std::move(operation) + ": " + std::error_code{ + code, + std::generic_category(), + }.message()); +} + +auto filesystem_status(std::string operation, std::error_code const& code) -> std::expected { + return noto::detail::fail_void(noto::detail::error_unavailable, std::move(operation) + ": " + code.message()); +} + +auto close_fd(std::int32_t fd, std::string_view operation) -> std::expected { + if (::close(fd) == 0) { + return {}; + } + return errno_status(std::string{operation}, errno); +} + +auto sync_file_path(std::string const& path, std::string_view operation) -> std::expected { + auto fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) { + return errno_status(std::string{operation}, errno); + } + if (::fsync(fd) != 0) { + auto status = errno_status(std::string{operation}, errno); + static_cast(::close(fd)); + return status; + } + return close_fd(fd, operation); +} + +auto sync_parent_directory(std::string const& path, std::string_view operation) -> std::expected { + auto parent = std::filesystem::path{path}.parent_path(); + if (parent.empty()) { + parent = "."; + } + auto directory = parent.string(); + auto fd = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd < 0) { + return errno_status(std::string{operation}, errno); + } + if (::fsync(fd) != 0) { + auto status = errno_status(std::string{operation}, errno); + static_cast(::close(fd)); + return status; + } + return close_fd(fd, operation); +} + +auto make_temporary_path(std::string const& target, std::string_view operation) -> std::expected { + auto target_path = std::filesystem::path{target}; + auto parent = target_path.parent_path(); + if (parent.empty()) { + parent = "."; + } + auto filename = target_path.filename().string(); + if (filename.empty()) { + filename = "noto"; + } + auto pattern = (parent / (filename + ".noto-tmp-XXXXXX")).string(); + auto buffer = std::vector{ + pattern.begin(), + pattern.end(), + }; + buffer.push_back('\0'); + auto fd = ::mkstemp(buffer.data()); + if (fd < 0) { + return std::unexpected(errno_status(std::string{operation}, errno).error()); + } + auto path = std::string{buffer.data()}; + auto closed = close_fd(fd, operation); + if (!closed) { + static_cast(::unlink(path.c_str())); + return std::unexpected(closed.error()); + } + return path; +} + +auto remove_file_noexcept(std::string const& path) noexcept -> void { + if (!path.empty()) { + static_cast(::unlink(path.c_str())); + } +} + +auto remove_file_if_exists(std::string const& path, std::string_view operation) -> std::expected { + if (::unlink(path.c_str()) == 0 || errno == ENOENT) { + return {}; + } + return errno_status(std::string{operation}, errno); +} + +auto remove_database_sidecars(std::string const& path, std::string_view operation) -> std::expected { + for (auto suffix : sqlite_sidecar_suffixes) { + auto status = remove_file_if_exists(path + std::string{suffix}, operation); + if (!status) { + return status; + } + } + return {}; +} + +auto remove_database_files_noexcept(std::string const& path) noexcept -> void { + remove_file_noexcept(path); + for (auto suffix : sqlite_sidecar_suffixes) { + remove_file_noexcept(path + std::string{suffix}); + } +} + +auto replace_file_atomically(std::string const& temporary, std::string const& destination, std::string_view operation) -> std::expected { + auto status = sync_file_path(temporary, operation); + if (!status) { + return status; + } + if (::rename(temporary.c_str(), destination.c_str()) != 0) { + return errno_status(std::string{operation}, errno); + } + return sync_parent_directory(destination, operation); +} + +auto validate_database_name(std::string_view name, std::string_view label) -> std::expected { + if (name.empty()) { + return invalid(std::string{label} + " database name must not be empty"); + } + if (contains_null(name)) { + return invalid(std::string{label} + " database name must not contain null bytes"); + } + return {}; +} + +auto open_flags(noto::connection_config const& config) -> std::int32_t { + auto flags = SQLITE_OPEN_FULLMUTEX; + switch (config.mode) { + case noto::open_mode::READ_ONLY: + flags |= SQLITE_OPEN_READONLY; + break; + case noto::open_mode::READ_WRITE: + flags |= SQLITE_OPEN_READWRITE; + break; + case noto::open_mode::READ_WRITE_CREATE: + case noto::open_mode::MEMORY: + flags |= SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + break; + } + if (config.uri) { + flags |= SQLITE_OPEN_URI; + } + return flags; +} + +auto valid_token(std::string_view value) -> std::bool_t { + if (value.empty() || contains_null(value)) { + return false; + } + for (auto character : value) { + auto byte = static_cast(character); + if ((byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z') || (byte >= '0' && byte <= '9') || character == '_') { + continue; + } + return false; + } + return true; +} + +auto transaction_mode_sql(noto::transaction_mode mode) -> std::string_view { + switch (mode) { + case noto::transaction_mode::DEFERRED: + return "BEGIN DEFERRED"; + case noto::transaction_mode::IMMEDIATE: + return "BEGIN IMMEDIATE"; + case noto::transaction_mode::EXCLUSIVE: + return "BEGIN EXCLUSIVE"; + } + return "BEGIN IMMEDIATE"; +} + +auto begin_operation(noto::detail::connection_core& core, std::string_view operation) -> std::expected { + if (core.current_state() != noto::connection_state::CONNECTED || core.database == nullptr) { + return unavailable(std::string{operation} + " requires an open database connection"); + } + if (core.busy) { + return unavailable("database connection already has an active operation"); + } + core.busy = true; + return {}; +} + +auto finish_operation(noto::detail::connection_core& core, std::expected status) -> std::expected { + core.busy = false; + if (!status && status.error().code() == noto::detail::error_data_loss) { + core.set_state(noto::connection_state::FAILED); + } + return status; +} + +auto validate_backup_options(noto::backup_options const& options) -> std::expected { + if (options.pages_per_step <= 0) { + return invalid("backup pages per step must be positive"); + } + auto source = validate_database_name(options.source_name, "backup source"); + if (!source) { + return source; + } + auto destination = validate_database_name(options.destination_name, "backup destination"); + if (!destination) { + return destination; + } + auto delay_status = validate_sqlite_milliseconds(options.retry_delay, "backup retry delay"); + if (!delay_status) { + return delay_status; + } + if (options.retry_timeout) { + auto timeout_status = validate_sqlite_milliseconds(*options.retry_timeout, "backup retry timeout"); + if (!timeout_status) { + return timeout_status; + } + } + return {}; +} + +auto validate_export_options(noto::export_options const& options) -> std::expected { + if (options.rows_per_yield == 0) { + return invalid("SQL export rows per yield must be positive"); + } + return {}; +} + +auto exec_direct(sqlite3* database, std::string const& sql, std::string_view operation) -> std::expected { + auto* error = static_cast(nullptr); + auto code = sqlite3_exec(database, sql.c_str(), nullptr, nullptr, &error); + if (code == SQLITE_OK) { + return {}; + } + auto message = std::string{}; + if (!operation.empty()) { + message += operation; + message += ": "; + } + message += error == nullptr ? sqlite3_errmsg(database) : error; + sqlite3_free(error); + auto mapped = noto::error_from(code, database); + return noto::detail::fail_void(mapped.code(), std::move(message)); +} + +auto configure_busy_timeout(sqlite3* database, std::chrono::milliseconds timeout, std::string_view operation) -> std::expected { + return noto::result_from(sqlite3_busy_timeout(database, static_cast(timeout.count())), database, operation); +} + +auto pragma_value_sql(std::variant const& value) -> std::expected { + return std::visit([](auto const& current) -> std::expected { + if constexpr (std::is_same_v, std::bool_t>) { + return std::string{current ? "1" : "0"}; + } else if constexpr (std::is_same_v, std::int64_t>) { + return std::to_string(current); + } else { + return noto::detail::quote_sql_literal(current, "pragma value"); + } + }, value); +} + +auto set_pragma(sqlite3* database, std::string const& name, std::variant const& value) -> std::expected { + if (!valid_token(name)) { + return invalid("database pragma names must contain only letters, digits, or underscores"); + } + auto rendered = pragma_value_sql(value); + if (!rendered) { + return std::unexpected(rendered.error()); + } + return exec_direct(database, "PRAGMA " + name + " = " + *rendered, "set pragma"); +} + +auto open_configured_database(noto::connection_config const& config, std::chrono::milliseconds busy_timeout, sqlite3** output) -> std::expected { + *output = nullptr; + auto* next_database = static_cast(nullptr); + auto filename = config.mode == noto::open_mode::MEMORY ? std::string{":memory:"} : config.filename; + auto code = sqlite3_open_v2(filename.c_str(), &next_database, open_flags(config), nullptr); + if (code != SQLITE_OK) { + auto status = noto::result_from(code, next_database, "open database"); + if (next_database != nullptr) { + sqlite3_close(next_database); + } + return status; + } + sqlite3_extended_result_codes(next_database, 1); + auto status = configure_busy_timeout(next_database, busy_timeout, "configure busy timeout"); + if (status) { + status = exec_direct(next_database, std::string{"PRAGMA foreign_keys = "} + (config.foreign_keys ? "1" : "0"), "configure foreign keys"); + } + for (auto const& pragma : config.pragmas) { + if (!status) { + break; + } + status = set_pragma(next_database, pragma.name, pragma.value); + } + if (!status) { + sqlite3_close(next_database); + return status; + } + *output = next_database; + return {}; +} + +auto retry_timeout(noto::detail::connection_core const& core, noto::backup_options const& options) -> std::chrono::milliseconds { + return options.retry_timeout.value_or(core.busy_timeout); +} + +auto async_step_backup(noto::detail::connection_core& core, sqlite3_backup* backup, noto::backup_options const& options, std::string_view operation) -> boost::asio::awaitable> { + auto const deadline = std::chrono::steady_clock::now() + retry_timeout(core, options); + auto timer = boost::asio::steady_timer{core.strand}; + while (true) { + auto code = sqlite3_backup_step(backup, options.pages_per_step); + if (code == SQLITE_DONE || (code != SQLITE_OK && code != SQLITE_BUSY && code != SQLITE_LOCKED)) { + co_return code; + } + if (code == SQLITE_OK) { + co_await boost::asio::post(core.strand, boost::asio::use_awaitable); + continue; + } + + auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + co_return std::unexpected(timeout(std::string{operation} + " timed out while waiting for SQLite locks").error()); + } + auto delay = options.retry_delay; + auto remaining = std::chrono::duration_cast(deadline - now); + if (remaining < delay) { + delay = remaining; + } + if (delay.count() <= 0) { + co_return std::unexpected(timeout(std::string{operation} + " timed out while waiting for SQLite locks").error()); + } + timer.expires_after(delay); + co_await timer.async_wait(boost::asio::use_awaitable); + } +} + +auto close_sqlite_handle(sqlite3* handle, std::string_view operation, std::expected status) -> std::expected { + auto close_code = sqlite3_close(handle); + if (close_code == SQLITE_OK) { + return status; + } + if (status) { + status = noto::result_from(close_code, handle, operation); + } + sqlite3_close_v2(handle); + return status; +} + +auto async_copy_restore_source_to_file(noto::detail::connection_core& core, std::string const& path, std::string const& temporary, noto::backup_options const& options) -> boost::asio::awaitable> { + auto* source = static_cast(nullptr); + auto code = sqlite3_open_v2(path.c_str(), &source, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nullptr); + if (code != SQLITE_OK) { + auto status = noto::result_from(code, source, "open restore source"); + if (source != nullptr) { + sqlite3_close(source); + } + co_return status; + } + auto status = configure_busy_timeout(source, std::chrono::milliseconds{0}, "disable source busy timeout for restore"); + if (!status) { + sqlite3_close(source); + co_return status; + } + + auto* destination = static_cast(nullptr); + code = sqlite3_open_v2(temporary.c_str(), &destination, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nullptr); + if (code != SQLITE_OK) { + status = noto::result_from(code, destination, "open restore temporary destination"); + if (destination != nullptr) { + sqlite3_close(destination); + } + sqlite3_close(source); + co_return status; + } + status = configure_busy_timeout(destination, std::chrono::milliseconds{0}, "disable temporary busy timeout for restore"); + if (!status) { + sqlite3_close(destination); + sqlite3_close(source); + co_return status; + } + + auto* backup = sqlite3_backup_init(destination, "main", source, options.source_name.c_str()); + if (backup == nullptr) { + status = noto::result_from(sqlite3_errcode(destination), destination, "initialize restore"); + status = close_sqlite_handle(destination, "close restore temporary destination", status); + status = close_sqlite_handle(source, "close restore source", status); + co_return status; + } + auto stepped = co_await async_step_backup(core, backup, options, "restore database"); + auto finish_code = sqlite3_backup_finish(backup); + status = stepped ? noto::result_from(*stepped == SQLITE_DONE ? finish_code : *stepped, destination, "restore database") : noto::detail::fail_void(stepped.error()); + status = close_sqlite_handle(destination, "close restore temporary destination", status); + status = close_sqlite_handle(source, "close restore source", status); + co_return status; +} + +auto close_database_for_atomic_restore(noto::detail::connection_core& core) -> std::expected { + auto checkpoint = noto::result_from(sqlite3_wal_checkpoint_v2(core.database, nullptr, SQLITE_CHECKPOINT_TRUNCATE, nullptr, nullptr), core.database, "checkpoint database before restore"); + if (!checkpoint) { + return checkpoint; + } + auto* closing = std::exchange(core.database, nullptr); + auto code = sqlite3_close(closing); + if (code == SQLITE_OK) { + return {}; + } + core.database = closing; + return noto::result_from(code, core.database, "close database before restore"); +} + +auto reopen_database_after_restore(noto::detail::connection_core& core) -> std::expected { + auto* reopened = static_cast(nullptr); + auto status = open_configured_database(core.config, core.busy_timeout, &reopened); + if (!status) { + core.set_state(noto::connection_state::FAILED); + return status; + } + core.database = reopened; + core.set_state(noto::connection_state::CONNECTED); + return {}; +} + +auto restore_atomic_failure(noto::detail::connection_core& core, std::expected status) -> std::expected { + if (core.database != nullptr) { + core.set_state(noto::connection_state::CONNECTED); + return status; + } + auto reopened = reopen_database_after_restore(core); + return reopened ? status : reopened; +} + +auto statement_is_open(noto::detail::statement_core const& core) -> std::bool_t { + return core.statement != nullptr && !core.finalized.load(std::memory_order_acquire); +} + +auto untrack_statement(noto::detail::statement_core& statement) -> void { + if (!statement.counted) { + return; + } + statement.counted = false; + if (statement.connection && statement.connection->live_statements.load(std::memory_order_acquire) > 0) { + statement.connection->live_statements.fetch_sub(1, std::memory_order_acq_rel); + } +} + + +auto bind_value(sqlite3_stmt* statement, std::int32_t index, noto::sqlite_value const& value, sqlite3* database) -> std::expected { + auto size = std::visit([](auto const& current) -> std::size_t { + if constexpr (std::is_same_v, std::string> || std::is_same_v, std::vector>) { + return current.size(); + } else { + return std::size_t{}; + } + }, value.value); + if (size > sqlite_int_max()) { + return invalid("bound SQLite text/blob value is too large"); + } + auto code = std::visit([statement, index](auto const& current) -> std::int32_t { + if constexpr (std::is_same_v, std::monostate>) { + return sqlite3_bind_null(statement, index); + } else if constexpr (std::is_same_v, std::int64_t>) { + return sqlite3_bind_int64(statement, index, static_cast(current)); + } else if constexpr (std::is_same_v, std::float64_t>) { + return sqlite3_bind_double(statement, index, current); + } else if constexpr (std::is_same_v, std::string>) { + return sqlite3_bind_text(statement, index, current.data(), static_cast(current.size()), SQLITE_TRANSIENT); + } else { + return sqlite3_bind_blob(statement, index, current.data(), static_cast(current.size()), SQLITE_TRANSIENT); + } + }, value.value); + return noto::result_from(code, database, "bind parameter"); +} + +auto bind_values(sqlite3_stmt* statement, std::vector const& params, sqlite3* database) -> std::expected { + if (params.size() > sqlite_int_max()) { + return invalid("prepared statement parameter count is too large"); + } + auto expected = sqlite3_bind_parameter_count(statement); + if (expected != static_cast(params.size())) { + return invalid("prepared statement parameter count does not match bound value count"); + } + for (auto index = std::size_t{}; index < params.size(); ++index) { + auto status = bind_value(statement, static_cast(index + 1), params[index], database); + if (!status) { + return status; + } + } + return {}; +} + +auto column_value(sqlite3_stmt* statement, std::int32_t index) -> noto::sqlite_value { + switch (sqlite3_column_type(statement, index)) { + case SQLITE_INTEGER: + return noto::sqlite_value{static_cast(sqlite3_column_int64(statement, index))}; + case SQLITE_FLOAT: + return noto::sqlite_value{sqlite3_column_double(statement, index)}; + case SQLITE_TEXT: { + auto const* text = reinterpret_cast(sqlite3_column_text(statement, index)); + auto bytes = sqlite3_column_bytes(statement, index); + return noto::sqlite_value{ + text == nullptr ? std::string{} : std::string{ + text, + static_cast(bytes), + }, + }; + } + case SQLITE_BLOB: { + auto const* data = static_cast(sqlite3_column_blob(statement, index)); + auto bytes = sqlite3_column_bytes(statement, index); + auto blob = std::vector{}; + if (data != nullptr && bytes > 0) { + blob.assign(data, data + bytes); + } + return noto::sqlite_value{std::move(blob)}; + } + case SQLITE_NULL: + default: + return {}; + } +} + +auto current_row(sqlite3_stmt* statement) -> std::vector { + auto count = sqlite3_column_count(statement); + auto row = std::vector{}; + row.reserve(static_cast(count)); + for (auto index = 0; index < count; ++index) { + row.push_back(column_value(statement, index)); + } + return row; +} + +auto column_names(sqlite3_stmt* statement) -> std::vector { + auto count = sqlite3_column_count(statement); + auto names = std::vector{}; + names.reserve(static_cast(count)); + for (auto index = 0; index < count; ++index) { + auto const* name = sqlite3_column_name(statement, index); + names.push_back(name == nullptr ? std::string{} : std::string{name}); + } + return names; +} + +auto value_result_bytes(noto::sqlite_value const& value) -> std::size_t { + return std::visit([](auto const& current) -> std::size_t { + if constexpr (std::is_same_v, std::monostate>) { + return {}; + } else if constexpr (std::is_same_v, std::int64_t>) { + return sizeof(std::int64_t); + } else if constexpr (std::is_same_v, std::float64_t>) { + return sizeof(std::float64_t); + } else { + return current.size(); + } + }, value.value); +} + +auto add_result_bytes(std::size_t& current, std::size_t addition, noto::execute_options const& options) -> std::expected { + if (!options.max_result_bytes) { + if (addition > std::numeric_limits::max() - current) { + current = std::numeric_limits::max(); + } else { + current += addition; + } + return {}; + } + if (addition > *options.max_result_bytes || current > *options.max_result_bytes - addition) { + return resource_exhausted("query result exceeded configured byte limit"); + } + current += addition; + return {}; +} + +auto track_result_columns(std::vector const& columns, std::size_t& result_bytes, noto::execute_options const& options) -> std::expected { + for (auto const& column : columns) { + auto status = add_result_bytes(result_bytes, column.size(), options); + if (!status) { + return status; + } + } + return {}; +} + +auto append_result_row(noto::query_result& result, std::vector row, std::size_t& result_bytes, noto::execute_options const& options) -> std::expected { + if (options.max_rows && result.rows.size() >= *options.max_rows) { + return resource_exhausted("query returned more rows than configured limit"); + } + for (auto const& value : row) { + auto status = add_result_bytes(result_bytes, value_result_bytes(value), options); + if (!status) { + return status; + } + } + result.rows.push_back(std::move(row)); + return {}; +} + +auto reset_statement_after_execute_failure(sqlite3_stmt* statement, sqlite3* database, std::expected status) -> std::expected { + auto reset = noto::result_from(sqlite3_reset(statement), database, "reset statement after execute failure"); + return reset ? status : reset; +} + +auto trailing_sql_is_empty(std::char_t const* tail) -> std::bool_t { + if (tail == nullptr) { + return true; + } + while (*tail != '\0') { + if (!std::isspace(static_cast(*tail)) && *tail != ';') { + return false; + } + ++tail; + } + return true; +} + +auto prepare_statement(sqlite3* database, std::string const& sql, sqlite3_stmt** statement, std::string_view operation) -> std::expected { + auto const* tail = static_cast(nullptr); + auto code = sqlite3_prepare_v2(database, sql.c_str(), -1, statement, &tail); + auto status = noto::result_from(code, database, operation); + if (!status) { + return status; + } + if (*statement == nullptr) { + return {}; + } + if (!trailing_sql_is_empty(tail)) { + sqlite3_finalize(*statement); + *statement = nullptr; + return invalid("only one SQL statement may be executed at a time"); + } + return {}; +} + +auto same_identity(file_identity const& left, file_identity const& right) -> std::bool_t { + return left.device == right.device && left.inode == right.inode; +} + +auto normalized_path(std::string const& path, std::string_view operation) -> std::expected { + auto error = std::error_code{}; + auto absolute = std::filesystem::absolute(path, error); + if (error) { + return std::unexpected(filesystem_status(std::string{operation}, error).error()); + } + auto normalized = std::filesystem::weakly_canonical(absolute, error); + if (error) { + return std::unexpected(filesystem_status(std::string{operation}, error).error()); + } + return normalized.lexically_normal(); +} + +auto identity_for_path(std::filesystem::path const& path, std::string_view operation) -> std::expected, modulus::error> { + struct stat data {}; + auto text = path.string(); + if (::stat(text.c_str(), &data) == 0) { + auto identity = file_identity{}; + identity.device = data.st_dev; + identity.inode = data.st_ino; + return std::optional{identity}; + } + if (errno == ENOENT || errno == ENOTDIR) { + return std::optional{}; + } + return std::unexpected(errno_status(std::string{operation}, errno).error()); +} + +auto protected_path_for(std::string const& path, std::string_view operation) -> std::expected { + auto normalized = normalized_path(path, operation); + if (!normalized) { + return std::unexpected(normalized.error()); + } + auto identity = identity_for_path(*normalized, operation); + if (!identity) { + return std::unexpected(identity.error()); + } + return protected_path{ + .normalized = std::move(*normalized), + .identity = std::move(*identity), + }; +} + +auto same_protected_path(protected_path const& left, protected_path const& right) -> std::bool_t { + if (left.normalized == right.normalized) { + return true; + } + return left.identity && right.identity && same_identity(*left.identity, *right.identity); +} + +auto live_database_paths(sqlite3* database) -> std::expected, modulus::error> { + auto* raw_statement = static_cast(nullptr); + auto prepared = prepare_statement(database, "PRAGMA database_list", &raw_statement, "prepare open database list"); + if (!prepared) { + return failure_from>(prepared); + } + auto statement = std::unique_ptr{ + raw_statement, + sqlite3_finalize, + }; + auto paths = std::vector{}; + while (true) { + auto code = sqlite3_step(statement.get()); + if (code == SQLITE_DONE) { + return paths; + } + if (code != SQLITE_ROW) { + return failure_from>(noto::result_from(code, database, "collect open database list")); + } + if (sqlite3_column_type(statement.get(), 2) != SQLITE_TEXT) { + continue; + } + auto const* text = reinterpret_cast(sqlite3_column_text(statement.get(), 2)); + auto bytes = sqlite3_column_bytes(statement.get(), 2); + auto path = text == nullptr ? std::string{} : std::string{ + text, + static_cast(bytes), + }; + if (!path.empty()) { + paths.push_back(std::move(path)); + } + } +} + +auto validate_output_not_live_database(sqlite3* database, std::string const& path, std::string_view label) -> std::expected { + auto target = protected_path_for(path, std::string{"check "} + std::string{label}); + if (!target) { + return std::unexpected(target.error()); + } + auto live_paths = live_database_paths(database); + if (!live_paths) { + return std::unexpected(live_paths.error()); + } + for (auto const& live_path : *live_paths) { + auto protected_live = protected_path_for(live_path, "check open database path"); + if (!protected_live) { + return std::unexpected(protected_live.error()); + } + if (same_protected_path(*target, *protected_live)) { + return invalid(std::string{label} + " must not target an open database file"); + } + for (auto suffix : sqlite_sidecar_suffixes) { + auto protected_sidecar = protected_path_for(live_path + std::string{suffix}, "check open database sidecar path"); + if (!protected_sidecar) { + return std::unexpected(protected_sidecar.error()); + } + if (same_protected_path(*target, *protected_sidecar)) { + return invalid(std::string{label} + " must not target an open database sidecar"); + } + } + } + return {}; +} + +auto execute_prepared(sqlite3* database, std::string const& sql, std::vector const& params, noto::execute_options const& options) -> std::expected { + auto* raw_statement = static_cast(nullptr); + auto prepared = prepare_statement(database, sql, &raw_statement, "prepare statement"); + if (!prepared) { + return failure_from(prepared); + } + if (raw_statement == nullptr) { + return noto::query_result{ + .changes = sqlite3_changes64(database), + .last_insert_rowid = sqlite3_last_insert_rowid(database), + }; + } + auto statement = std::unique_ptr{ + raw_statement, + sqlite3_finalize, + }; + auto bound = bind_values(statement.get(), params, database); + if (!bound) { + return failure_from(bound); + } + auto result = noto::query_result{}; + auto result_bytes = std::size_t{}; + result.columns = column_names(statement.get()); + auto status = track_result_columns(result.columns, result_bytes, options); + if (!status) { + return failure_from(status); + } + while (true) { + auto code = sqlite3_step(statement.get()); + if (code == SQLITE_ROW) { + status = append_result_row(result, current_row(statement.get()), result_bytes, options); + if (!status) { + return failure_from(status); + } + continue; + } + if (code == SQLITE_DONE) { + result.changes = sqlite3_changes64(database); + result.last_insert_rowid = sqlite3_last_insert_rowid(database); + return result; + } + return failure_from(noto::result_from(code, database, "execute statement")); + } +} + +auto hex_literal(std::byte const* data, std::size_t size) -> std::string { + auto sql = std::ostringstream{}; + sql << "X'"; + for (auto index = std::size_t{}; index < size; ++index) { + sql << std::hex << std::setw(2) << std::setfill('0') << static_cast(std::to_integer(data[index])); + } + sql << "'"; + return sql.str(); +} + +auto text_hex_literal(std::string const& value) -> std::string { + auto const* bytes = reinterpret_cast(value.data()); + return "CAST(" + hex_literal(bytes, value.size()) + " AS TEXT)"; +} + +auto literal_sql(noto::sqlite_value const& value) -> std::expected { + return std::visit([](auto const& current) -> std::expected { + if constexpr (std::is_same_v, std::monostate>) { + return "NULL"; + } else if constexpr (std::is_same_v, std::int64_t>) { + return std::to_string(current); + } else if constexpr (std::is_same_v, std::float64_t>) { + if (!std::isfinite(current)) { + return noto::detail::fail(noto::detail::error_invalid_argument, "exported real value must be finite"); + } + auto sql = std::ostringstream{}; + sql << std::setprecision(17) << current; + return sql.str(); + } else if constexpr (std::is_same_v, std::string>) { + if (contains_null(current)) { + return text_hex_literal(current); + } + auto literal = noto::detail::quote_sql_literal(current, "export value"); + if (!literal) { + return std::unexpected(literal.error()); + } + return *literal; + } else { + return hex_literal(current.data(), current.size()); + } + }, value.value); +} + +auto write_limited(std::ostream& output, std::string_view text, noto::export_options const& options, std::size_t& written) -> std::expected { + if (text.size() > static_cast(std::numeric_limits::max())) { + return resource_exhausted("SQL export chunk is too large"); + } + if (options.max_output_bytes && (text.size() > *options.max_output_bytes || written > *options.max_output_bytes - text.size())) { + return resource_exhausted("SQL export exceeded configured output byte limit"); + } + output.write(text.data(), static_cast(text.size())); + if (!output) { + return unavailable("failed to write SQL export output"); + } + if (text.size() > std::numeric_limits::max() - written) { + written = std::numeric_limits::max(); + } else { + written += text.size(); + } + return {}; +} + +auto column_text(sqlite3_stmt* statement, std::int32_t index) -> std::optional { + if (sqlite3_column_type(statement, index) != SQLITE_TEXT) { + return std::nullopt; + } + auto const* text = reinterpret_cast(sqlite3_column_text(statement, index)); + auto bytes = sqlite3_column_bytes(statement, index); + return text == nullptr ? std::string{} : std::string{ + text, + static_cast(bytes), + }; +} + +auto identifier_character(std::char_t character) -> std::bool_t { + auto byte = static_cast(character); + return std::isalnum(byte) || character == '_'; +} + +auto consume_keyword(std::string_view sql, std::size_t& position, std::string_view keyword) -> std::bool_t { + auto current = position; + while (current < sql.size() && std::isspace(static_cast(sql[current]))) { + ++current; + } + if (keyword.size() > sql.size() - current) { + return false; + } + for (auto index = std::size_t{}; index < keyword.size(); ++index) { + auto left = static_cast(sql[current + index]); + auto right = static_cast(keyword[index]); + if (std::tolower(left) != std::tolower(right)) { + return false; + } + } + auto end = current + keyword.size(); + if (end < sql.size() && identifier_character(sql[end])) { + return false; + } + position = end; + return true; +} + +auto create_virtual_table_sql(std::string_view sql) -> std::bool_t { + auto position = std::size_t{}; + if (!consume_keyword(sql, position, "CREATE")) { + return false; + } + auto after_create = position; + if (!consume_keyword(sql, position, "TEMP") && !consume_keyword(sql, position, "TEMPORARY")) { + position = after_create; + } + return consume_keyword(sql, position, "VIRTUAL") && consume_keyword(sql, position, "TABLE"); +} + +auto table_exists(sqlite3* database, std::string const& table_name) -> std::expected { + auto* raw_statement = static_cast(nullptr); + auto prepared = prepare_statement(database, "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1", &raw_statement, "prepare table existence check"); + if (!prepared) { + return failure_from(prepared); + } + auto statement = std::unique_ptr{ + raw_statement, + sqlite3_finalize, + }; + auto code = sqlite3_bind_text(statement.get(), 1, table_name.data(), static_cast(table_name.size()), SQLITE_TRANSIENT); + auto status = noto::result_from(code, database, "bind table existence check"); + if (!status) { + return failure_from(status); + } + code = sqlite3_step(statement.get()); + if (code == SQLITE_ROW) { + return true; + } + if (code == SQLITE_DONE) { + return false; + } + return failure_from(noto::result_from(code, database, "check table existence")); +} + +auto exportable_columns(sqlite3* database, std::string const& table_name) -> std::expected, modulus::error> { + auto quoted_table = noto::quote_identifier(table_name); + if (!quoted_table) { + return failure_from>(quoted_table.error()); + } + auto* raw_statement = static_cast(nullptr); + auto prepared = prepare_statement(database, "PRAGMA table_xinfo(" + *quoted_table + ")", &raw_statement, "prepare export columns"); + if (!prepared) { + return failure_from>(prepared); + } + auto statement = std::unique_ptr{ + raw_statement, + sqlite3_finalize, + }; + if (sqlite3_column_count(statement.get()) < 7) { + return noto::detail::fail>(noto::detail::error_unavailable, "SQLite table_xinfo did not return hidden column metadata"); + } + auto columns = std::vector{}; + while (true) { + auto code = sqlite3_step(statement.get()); + if (code == SQLITE_DONE) { + return columns; + } + if (code != SQLITE_ROW) { + return failure_from>(noto::result_from(code, database, "collect export columns")); + } + auto hidden = sqlite3_column_int(statement.get(), 6); + if (hidden != 0) { + continue; + } + auto name = column_text(statement.get(), 1); + if (!name) { + continue; + } + auto quoted_name = noto::quote_identifier(*name); + if (!quoted_name) { + return failure_from>(quoted_name.error()); + } + columns.push_back(export_column{ + .quoted_name = std::move(*quoted_name), + }); + } +} + +auto collect_export_schema(sqlite3* database) -> std::expected { + auto* raw_statement = static_cast(nullptr); + auto prepared = prepare_statement( + database, + "SELECT type, name, sql FROM sqlite_schema WHERE sql IS NOT NULL AND name NOT GLOB 'sqlite_*' " + "ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'trigger' THEN 2 WHEN 'view' THEN 3 ELSE 4 END, name", + &raw_statement, + "prepare export schema" + ); + if (!prepared) { + return failure_from(prepared); + } + auto statement = std::unique_ptr{ + raw_statement, + sqlite3_finalize, + }; + auto schema = export_schema{}; + while (true) { + auto code = sqlite3_step(statement.get()); + if (code == SQLITE_ROW) { + auto type = column_text(statement.get(), 0); + auto name = column_text(statement.get(), 1); + auto sql = column_text(statement.get(), 2); + if (!type || !name || !sql) { + continue; + } + if (*type == "table") { + if (create_virtual_table_sql(*sql)) { + return noto::detail::fail(noto::detail::error_invalid_argument, "SQL export does not support virtual tables"); + } + auto quoted_name = noto::quote_identifier(*name); + if (!quoted_name) { + return failure_from(quoted_name.error()); + } + auto columns = exportable_columns(database, *name); + if (!columns) { + return failure_from(columns.error()); + } + schema.table_sql.push_back(*sql); + schema.tables.push_back(export_table{ + .quoted_name = std::move(*quoted_name), + .columns = std::move(*columns), + }); + } else if (*type == "index" || *type == "view" || *type == "trigger") { + schema.post_data_sql.push_back(*sql); + } + continue; + } + if (code == SQLITE_DONE) { + auto sequence_exists = table_exists(database, "sqlite_sequence"); + if (!sequence_exists) { + return failure_from(sequence_exists.error()); + } + schema.has_sqlite_sequence = *sequence_exists; + return schema; + } + return failure_from(noto::result_from(code, database, "collect export schema")); + } +} + +auto export_table_rows(noto::detail::connection_core& core, export_table const& table, std::function(std::string_view)> const& write, noto::export_options const& options) -> boost::asio::awaitable> { + auto* raw_statement = static_cast(nullptr); + auto select_sql = std::string{}; + if (table.columns.empty()) { + select_sql = "SELECT 1 FROM " + table.quoted_name; + } else { + select_sql = "SELECT "; + for (auto index = std::size_t{}; index < table.columns.size(); ++index) { + if (index != 0) { + select_sql += ", "; + } + select_sql += table.columns[index].quoted_name; + } + select_sql += " FROM "; + select_sql += table.quoted_name; + } + auto prepared = prepare_statement(core.database, select_sql, &raw_statement, "prepare export rows"); + if (!prepared) { + co_return prepared; + } + auto statement = std::unique_ptr{ + raw_statement, + sqlite3_finalize, + }; + auto rows_since_yield = std::size_t{}; + while (true) { + auto code = sqlite3_step(statement.get()); + if (code == SQLITE_DONE) { + co_return std::expected{}; + } + if (code != SQLITE_ROW) { + co_return noto::result_from(code, core.database, "export table rows"); + } + auto row_sql = std::ostringstream{}; + if (table.columns.empty()) { + row_sql << "INSERT INTO " << table.quoted_name << " DEFAULT VALUES;\n"; + } else { + row_sql << "INSERT INTO " << table.quoted_name << "("; + for (auto index = std::size_t{}; index < table.columns.size(); ++index) { + if (index != 0) { + row_sql << ", "; + } + row_sql << table.columns[index].quoted_name; + } + row_sql << ") VALUES("; + for (auto index = std::size_t{}; index < table.columns.size(); ++index) { + if (index != 0) { + row_sql << ", "; + } + auto literal = literal_sql(column_value(statement.get(), static_cast(index))); + if (!literal) { + co_return std::unexpected(literal.error()); + } + row_sql << *literal; + } + row_sql << ");\n"; + } + auto status = write(row_sql.str()); + if (!status) { + co_return status; + } + ++rows_since_yield; + if (rows_since_yield >= options.rows_per_yield) { + rows_since_yield = 0; + co_await boost::asio::post(core.strand, boost::asio::use_awaitable); + } + } +} + +auto export_sqlite_sequence(noto::detail::connection_core& core, std::function(std::string_view)> const& write, noto::export_options const& options) -> boost::asio::awaitable> { + auto status = write("DELETE FROM \"sqlite_sequence\";\n"); + if (!status) { + co_return status; + } + auto* raw_statement = static_cast(nullptr); + auto prepared = prepare_statement(core.database, "SELECT name, seq FROM sqlite_sequence", &raw_statement, "prepare sqlite_sequence export"); + if (!prepared) { + co_return prepared; + } + auto statement = std::unique_ptr{ + raw_statement, + sqlite3_finalize, + }; + auto rows_since_yield = std::size_t{}; + while (true) { + auto code = sqlite3_step(statement.get()); + if (code == SQLITE_DONE) { + co_return std::expected{}; + } + if (code != SQLITE_ROW) { + co_return noto::result_from(code, core.database, "export sqlite_sequence"); + } + auto name = literal_sql(column_value(statement.get(), 0)); + if (!name) { + co_return std::unexpected(name.error()); + } + auto sequence = literal_sql(column_value(statement.get(), 1)); + if (!sequence) { + co_return std::unexpected(sequence.error()); + } + auto row_sql = std::ostringstream{}; + row_sql << "INSERT INTO \"sqlite_sequence\"(\"name\", \"seq\") VALUES(" << *name << ", " << *sequence << ");\n"; + status = write(row_sql.str()); + if (!status) { + co_return status; + } + ++rows_since_yield; + if (rows_since_yield >= options.rows_per_yield) { + rows_since_yield = 0; + co_await boost::asio::post(core.strand, boost::asio::use_awaitable); + } + } +} + +auto write_export(noto::detail::connection_core& core, std::function(std::string_view)> const& write, noto::export_options const& options) -> boost::asio::awaitable> { + auto schema = collect_export_schema(core.database); + if (!schema) { + co_return std::unexpected(schema.error()); + } + auto status = write("PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n"); + if (!status) { + co_return status; + } + for (auto const& sql : schema->table_sql) { + status = write(sql); + if (status) { + status = write(";\n"); + } + if (!status) { + co_return status; + } + } + for (auto const& table : schema->tables) { + status = co_await export_table_rows(core, table, write, options); + if (!status) { + co_return status; + } + } + if (schema->has_sqlite_sequence) { + status = co_await export_sqlite_sequence(core, write, options); + if (!status) { + co_return status; + } + } + for (auto const& sql : schema->post_data_sql) { + status = write(sql); + if (status) { + status = write(";\n"); + } + if (!status) { + co_return status; + } + } + co_return write("COMMIT;\nPRAGMA foreign_keys=ON;\n"); +} + +pending_statement_finalize::pending_statement_finalize(std::shared_ptr owner_value, sqlite3_stmt* statement_value, std::bool_t counted_value) : owner{std::move(owner_value)}, statement{statement_value}, counted{counted_value} {} + +pending_statement_finalize::~pending_statement_finalize() { + finish(); +} + +auto pending_statement_finalize::finish() noexcept -> void { + auto* current = std::exchange(statement, nullptr); + if (current != nullptr) { + static_cast(sqlite3_finalize(current)); + } + if (counted && owner && owner->live_statements.load(std::memory_order_acquire) > 0) { + owner->live_statements.fetch_sub(1, std::memory_order_acq_rel); + } + counted = false; +} + +pending_transaction_rollback::pending_transaction_rollback(std::shared_ptr owner_value) : owner{std::move(owner_value)} {} + +pending_transaction_rollback::~pending_transaction_rollback() { + finish(); +} + +auto pending_transaction_rollback::finish() noexcept -> void { + if (!active) { + return; + } + active = false; + if (owner && owner->database != nullptr && owner->transaction_active) { + static_cast(exec_direct(owner->database, "ROLLBACK", "rollback abandoned transaction")); + owner->transaction_active = false; + } +} + +} + +noto::detail::connection_core::connection_core(boost::asio::any_io_executor executor_value, noto::connection_config config_value) : executor{std::move(executor_value)}, strand{boost::asio::make_strand(executor)}, config{std::move(config_value)}, busy_timeout{config.busy_timeout} {} + +noto::detail::connection_core::~connection_core() { + if (database != nullptr) { + sqlite3_close_v2(database); + database = nullptr; + } +} + +auto noto::detail::connection_core::current_state() const -> noto::connection_state { + return state ? state->load(std::memory_order_acquire) : noto::connection_state::CLOSED; +} + +auto noto::detail::connection_core::set_state(noto::connection_state next) -> void { + state->store(next, std::memory_order_release); +} + +auto noto::detail::connection_core::mark_failed(std::string) -> void { + set_state(noto::connection_state::FAILED); +} + +auto noto::detail::connection_core::abandon_transaction() -> void { + auto self = shared_from_this(); + auto cleanup = std::shared_ptr{}; + try { + cleanup = std::make_shared(self); + boost::asio::post(strand, [cleanup] { + cleanup->finish(); + }); + } catch (...) { + if (cleanup) { + cleanup->finish(); + } else { + auto fallback = pending_transaction_rollback{std::move(self)}; + fallback.finish(); + } + } +} + +auto noto::detail::connection_core::async_connect() -> boost::asio::awaitable> { + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + if (current_state() == noto::connection_state::CONNECTED) { + co_return std::expected{}; + } + if (busy) { + co_return unavailable("database connection already has an active operation"); + } + auto valid = noto::validate(config); + if (!valid) { + co_return valid; + } + busy = true; + auto* next_database = static_cast(nullptr); + auto status = open_configured_database(config, busy_timeout, &next_database); + if (!status) { + busy = false; + set_state(noto::connection_state::FAILED); + co_return status; + } + database = next_database; + busy = false; + set_state(noto::connection_state::CONNECTED); + co_return std::expected{}; +} + +auto noto::detail::connection_core::async_close() -> boost::asio::awaitable> { + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + if (database == nullptr || current_state() == noto::connection_state::IDLE || current_state() == noto::connection_state::CLOSED) { + set_state(noto::connection_state::CLOSED); + co_return std::expected{}; + } + if (busy) { + co_return unavailable("database connection cannot close while an operation is active"); + } + if (live_statements.load(std::memory_order_acquire) != 0) { + co_return unavailable("database connection cannot close while prepared statements are active"); + } + set_state(noto::connection_state::CLOSING); + if (transaction_active) { + auto rolled_back = exec_direct(database, "ROLLBACK", "rollback active transaction during close"); + if (!rolled_back) { + set_state(rolled_back.error().code() == noto::detail::error_data_loss ? noto::connection_state::FAILED : noto::connection_state::CONNECTED); + co_return rolled_back; + } + transaction_active = false; + } + auto* closing = std::exchange(database, nullptr); + auto code = sqlite3_close(closing); + if (code != SQLITE_OK) { + database = closing; + if (code == SQLITE_BUSY) { + set_state(noto::connection_state::CONNECTED); + co_return unavailable("database connection cannot close while prepared statements are active"); + } + set_state(noto::connection_state::FAILED); + co_return noto::result_from(code, database, "close database"); + } + set_state(noto::connection_state::CLOSED); + co_return std::expected{}; +} + +auto noto::detail::connection_core::async_execute(std::string sql, std::vector params, noto::execute_options options) -> boost::asio::awaitable> { + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + auto ready = begin_operation(*this, "execute query"); + if (!ready) { + co_return std::unexpected(ready.error()); + } + auto result = execute_prepared(database, sql, params, options); + if (!result) { + auto status = finish_operation(*this, noto::detail::fail_void(result.error())); + co_return std::unexpected(status.error()); + } + static_cast(finish_operation(*this, {})); + co_return result; +} + +auto noto::detail::connection_core::async_prepare(std::string sql) -> boost::asio::awaitable> { + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + auto ready = begin_operation(*this, "prepare statement"); + if (!ready) { + co_return std::unexpected(ready.error()); + } + auto* raw_statement = static_cast(nullptr); + auto prepared = prepare_statement(database, sql, &raw_statement, "prepare statement"); + auto status = finish_operation(*this, prepared); + if (!status) { + co_return std::unexpected(status.error()); + } + if (raw_statement == nullptr) { + co_return std::unexpected(invalid("prepared statement SQL must not be empty").error()); + } + co_return noto::statement{std::make_shared(shared_from_this(), raw_statement)}; +} + +auto noto::detail::connection_core::async_begin_transaction(noto::transaction_mode mode) -> boost::asio::awaitable> { + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + auto ready = begin_operation(*this, "begin transaction"); + if (!ready) { + co_return std::unexpected(ready.error()); + } + if (transaction_active) { + static_cast(finish_operation(*this, {})); + co_return std::unexpected(unavailable("database connection already has an active transaction").error()); + } + auto status = exec_direct(database, std::string{transaction_mode_sql(mode)}, "begin transaction"); + status = finish_operation(*this, status); + if (!status) { + co_return std::unexpected(status.error()); + } + transaction_active = true; + co_return noto::transaction{shared_from_this()}; +} + +auto noto::detail::connection_core::async_commit_transaction() -> boost::asio::awaitable { + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + auto ready = begin_operation(*this, "commit transaction"); + if (!ready) { + co_return noto::detail::transaction_result{ + .status = ready, + .active = transaction_active, + }; + } + if (!transaction_active) { + static_cast(finish_operation(*this, {})); + co_return noto::detail::transaction_result{ + .status = invalid("transaction is not active"), + .active = false, + }; + } + auto status = exec_direct(database, "COMMIT", "commit transaction"); + if (status) { + transaction_active = false; + } + status = finish_operation(*this, status); + co_return noto::detail::transaction_result{ + .status = status, + .active = transaction_active, + }; +} + +auto noto::detail::connection_core::async_rollback_transaction() -> boost::asio::awaitable { + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + auto ready = begin_operation(*this, "rollback transaction"); + if (!ready) { + co_return noto::detail::transaction_result{ + .status = ready, + .active = transaction_active, + }; + } + if (!transaction_active) { + static_cast(finish_operation(*this, {})); + co_return noto::detail::transaction_result{ + .status = invalid("transaction is not active"), + .active = false, + }; + } + auto status = exec_direct(database, "ROLLBACK", "rollback transaction"); + if (status) { + transaction_active = false; + } + status = finish_operation(*this, status); + co_return noto::detail::transaction_result{ + .status = status, + .active = transaction_active, + }; +} + +auto noto::detail::connection_core::async_backup_to_file(std::string path, noto::backup_options options) -> boost::asio::awaitable> { + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + auto ready = begin_operation(*this, "backup database"); + if (!ready) { + co_return ready; + } + auto path_status = validate_path(path, "backup destination"); + if (!path_status) { + co_return finish_operation(*this, path_status); + } + auto options_status = validate_backup_options(options); + if (!options_status) { + co_return finish_operation(*this, options_status); + } + auto destination_status = validate_output_not_live_database(database, path, "backup destination"); + if (!destination_status) { + co_return finish_operation(*this, destination_status); + } + auto temporary = make_temporary_path(path, "create backup temporary file"); + if (!temporary) { + co_return finish_operation(*this, noto::detail::fail_void(temporary.error())); + } + auto* destination = static_cast(nullptr); + auto code = sqlite3_open_v2(temporary->c_str(), &destination, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nullptr); + if (code != SQLITE_OK) { + auto status = noto::result_from(code, destination, "open backup destination"); + if (destination != nullptr) { + sqlite3_close(destination); + } + remove_file_noexcept(*temporary); + co_return finish_operation(*this, status); + } + auto timeout_disabled = configure_busy_timeout(database, std::chrono::milliseconds{0}, "disable source busy timeout for backup"); + if (!timeout_disabled) { + sqlite3_close(destination); + remove_file_noexcept(*temporary); + co_return finish_operation(*this, timeout_disabled); + } + auto restore_source_timeout = [this](std::expected status) { + auto restored = configure_busy_timeout(database, busy_timeout, "restore source busy timeout after backup"); + return status ? restored : status; + }; + auto* backup = sqlite3_backup_init(destination, options.destination_name.c_str(), database, options.source_name.c_str()); + if (backup == nullptr) { + auto status = noto::result_from(sqlite3_errcode(destination), destination, "initialize backup"); + sqlite3_close(destination); + remove_file_noexcept(*temporary); + co_return finish_operation(*this, restore_source_timeout(status)); + } + auto stepped = co_await async_step_backup(*this, backup, options, "backup database"); + auto finish_code = sqlite3_backup_finish(backup); + auto status = stepped ? noto::result_from(*stepped == SQLITE_DONE ? finish_code : *stepped, destination, "backup database") : noto::detail::fail_void(stepped.error()); + status = restore_source_timeout(status); + auto close_code = sqlite3_close(destination); + if (close_code != SQLITE_OK) { + if (status) { + status = noto::result_from(close_code, destination, "close backup destination"); + } + sqlite3_close_v2(destination); + } + if (status) { + status = replace_file_atomically(*temporary, path, "replace backup destination"); + } + if (!status) { + remove_file_noexcept(*temporary); + } + co_return finish_operation(*this, status); +} + +auto noto::detail::connection_core::async_restore_from_file(std::string path, noto::backup_options options) -> boost::asio::awaitable> { + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + auto ready = begin_operation(*this, "restore database"); + if (!ready) { + co_return ready; + } + auto path_status = validate_path(path, "restore source"); + if (!path_status) { + co_return finish_operation(*this, path_status); + } + auto options_status = validate_backup_options(options); + if (!options_status) { + co_return finish_operation(*this, options_status); + } + if (config.mode == noto::open_mode::MEMORY || config.uri || config.filename == ":memory:") { + co_return finish_operation(*this, invalid("atomic restore requires a file-backed database")); + } + if (config.mode == noto::open_mode::READ_ONLY) { + co_return finish_operation(*this, invalid("atomic restore requires a writable database")); + } + if (options.destination_name != "main") { + co_return finish_operation(*this, invalid("atomic restore only supports the main destination database")); + } + if (transaction_active) { + co_return finish_operation(*this, unavailable("database connection cannot restore while a transaction is active")); + } + if (live_statements.load(std::memory_order_acquire) != 0) { + co_return finish_operation(*this, unavailable("database connection cannot restore while prepared statements are active")); + } + auto target_status = validate_path(config.filename, "restore destination"); + if (!target_status) { + co_return finish_operation(*this, target_status); + } + auto temporary = make_temporary_path(config.filename, "create restore temporary file"); + if (!temporary) { + co_return finish_operation(*this, noto::detail::fail_void(temporary.error())); + } + auto status = co_await async_copy_restore_source_to_file(*this, path, *temporary, options); + if (status) { + set_state(noto::connection_state::CLOSING); + status = close_database_for_atomic_restore(*this); + if (!status) { + status = restore_atomic_failure(*this, status); + } + } + if (status) { + status = remove_database_sidecars(config.filename, "remove stale database sidecar files before restore"); + if (!status) { + status = restore_atomic_failure(*this, status); + } + } + if (status) { + status = replace_file_atomically(*temporary, config.filename, "replace restored database"); + if (!status) { + status = restore_atomic_failure(*this, status); + } + } + if (status) { + status = reopen_database_after_restore(*this); + } + if (!status) { + remove_database_files_noexcept(*temporary); + } + co_return finish_operation(*this, status); +} + +auto noto::detail::connection_core::async_export_sql(noto::export_options options) -> boost::asio::awaitable> { + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + auto ready = begin_operation(*this, "export database"); + if (!ready) { + co_return std::unexpected(ready.error()); + } + auto options_status = validate_export_options(options); + if (!options_status) { + options_status = finish_operation(*this, options_status); + co_return std::unexpected(options_status.error()); + } + auto output = std::ostringstream{}; + auto written = std::size_t{}; + auto status = co_await write_export(*this, [&output, &options, &written](std::string_view text) { + return write_limited(output, text, options, written); + }, options); + if (!status) { + status = finish_operation(*this, status); + co_return std::unexpected(status.error()); + } + static_cast(finish_operation(*this, {})); + co_return output.str(); +} + +auto noto::detail::connection_core::async_export_sql_to_file(std::string path, noto::export_options options) -> boost::asio::awaitable> { + auto path_status = validate_path(path, "export"); + if (!path_status) { + co_return path_status; + } + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + auto ready = begin_operation(*this, "export database"); + if (!ready) { + co_return ready; + } + auto options_status = validate_export_options(options); + if (!options_status) { + co_return finish_operation(*this, options_status); + } + auto destination_status = validate_output_not_live_database(database, path, "export destination"); + if (!destination_status) { + co_return finish_operation(*this, destination_status); + } + auto temporary = make_temporary_path(path, "create export temporary file"); + if (!temporary) { + co_return finish_operation(*this, noto::detail::fail_void(temporary.error())); + } + auto output = std::ofstream{ + *temporary, + std::ios::binary | std::ios::trunc, + }; + if (!output) { + remove_file_noexcept(*temporary); + co_return finish_operation(*this, unavailable("failed to open export file")); + } + auto written = std::size_t{}; + auto status = co_await write_export(*this, [&output, &options, &written](std::string_view text) { + return write_limited(output, text, options, written); + }, options); + if (status) { + output.flush(); + if (!output) { + status = unavailable("failed to write export file"); + } + } + output.close(); + if (status && !output) { + status = unavailable("failed to close export file"); + } + if (status) { + status = replace_file_atomically(*temporary, path, "replace export file"); + } + if (!status) { + remove_file_noexcept(*temporary); + } + co_return finish_operation(*this, status); +} + +auto noto::detail::connection_core::async_set_busy_timeout(std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + auto timeout_status = validate_sqlite_milliseconds(timeout, "database busy timeout"); + if (!timeout_status) { + co_return timeout_status; + } + auto self = shared_from_this(); + co_await boost::asio::dispatch(strand, boost::asio::use_awaitable); + auto ready = begin_operation(*this, "set busy timeout"); + if (!ready) { + co_return ready; + } + auto status = configure_busy_timeout(database, timeout, "set busy timeout"); + if (status) { + busy_timeout = timeout; + } + co_return finish_operation(*this, status); +} + +noto::detail::statement_core::statement_core(std::shared_ptr connection_value, sqlite3_stmt* statement_value) : connection{std::move(connection_value)}, statement{statement_value}, counted{statement_value != nullptr} { + if (connection && counted) { + connection->live_statements.fetch_add(1, std::memory_order_acq_rel); + } +} + +noto::detail::statement_core::~statement_core() { + if (finalized.exchange(true, std::memory_order_acq_rel)) { + return; + } + auto* current = std::exchange(statement, nullptr); + auto owner = connection; + auto was_counted = counted; + counted = false; + if (current == nullptr) { + return; + } + if (!owner) { + static_cast(sqlite3_finalize(current)); + return; + } + auto cleanup = std::shared_ptr{}; + try { + cleanup = std::make_shared(owner, current, was_counted); + boost::asio::post(owner->strand, [cleanup] { + cleanup->finish(); + }); + } catch (...) { + if (cleanup) { + cleanup->finish(); + } else { + auto fallback = pending_statement_finalize{ + std::move(owner), + current, + was_counted, + }; + fallback.finish(); + } + } +} + +auto noto::detail::statement_core::async_reset(std::vector params) -> boost::asio::awaitable> { + auto owner = connection; + if (!owner) { + co_return invalid_statement(); + } + co_await boost::asio::dispatch(owner->strand, boost::asio::use_awaitable); + if (!statement_is_open(*this)) { + co_return invalid_statement(); + } + auto ready = begin_operation(*owner, "reset statement"); + if (!ready) { + co_return ready; + } + auto code = sqlite3_reset(statement); + auto status = noto::result_from(code, owner->database, "reset statement"); + if (status) { + code = sqlite3_clear_bindings(statement); + status = noto::result_from(code, owner->database, "clear statement bindings"); + } + if (status) { + status = bind_values(statement, params, owner->database); + } + co_return finish_operation(*owner, status); +} + +auto noto::detail::statement_core::async_step() -> boost::asio::awaitable>, modulus::error>> { + auto owner = connection; + if (!owner) { + co_return std::unexpected(invalid_statement().error()); + } + co_await boost::asio::dispatch(owner->strand, boost::asio::use_awaitable); + if (!statement_is_open(*this)) { + co_return std::unexpected(invalid_statement().error()); + } + auto ready = begin_operation(*owner, "step statement"); + if (!ready) { + co_return std::unexpected(ready.error()); + } + auto code = sqlite3_step(statement); + if (code == SQLITE_ROW) { + auto row = current_row(statement); + static_cast(finish_operation(*owner, {})); + co_return std::optional>{std::move(row)}; + } + if (code == SQLITE_DONE) { + static_cast(finish_operation(*owner, {})); + co_return std::optional>{}; + } + auto status = finish_operation(*owner, noto::result_from(code, owner->database, "step statement")); + co_return std::unexpected(status.error()); +} + +auto noto::detail::statement_core::async_execute(std::vector params, noto::execute_options options) -> boost::asio::awaitable> { + auto owner = connection; + if (!owner) { + co_return std::unexpected(invalid_statement().error()); + } + co_await boost::asio::dispatch(owner->strand, boost::asio::use_awaitable); + if (!statement_is_open(*this)) { + co_return std::unexpected(invalid_statement().error()); + } + auto ready = begin_operation(*owner, "execute statement"); + if (!ready) { + co_return std::unexpected(ready.error()); + } + auto code = sqlite3_reset(statement); + auto status = noto::result_from(code, owner->database, "reset statement"); + if (status) { + code = sqlite3_clear_bindings(statement); + status = noto::result_from(code, owner->database, "clear statement bindings"); + } + if (status) { + status = bind_values(statement, params, owner->database); + } + if (!status) { + status = finish_operation(*owner, status); + co_return std::unexpected(status.error()); + } + auto result = noto::query_result{}; + auto result_bytes = std::size_t{}; + result.columns = column_names(statement); + status = track_result_columns(result.columns, result_bytes, options); + if (!status) { + status = reset_statement_after_execute_failure(statement, owner->database, status); + status = finish_operation(*owner, status); + co_return std::unexpected(status.error()); + } + while (true) { + code = sqlite3_step(statement); + if (code == SQLITE_ROW) { + status = append_result_row(result, current_row(statement), result_bytes, options); + if (!status) { + status = reset_statement_after_execute_failure(statement, owner->database, status); + status = finish_operation(*owner, status); + co_return std::unexpected(status.error()); + } + continue; + } + if (code == SQLITE_DONE) { + result.changes = sqlite3_changes64(owner->database); + result.last_insert_rowid = sqlite3_last_insert_rowid(owner->database); + static_cast(finish_operation(*owner, {})); + co_return result; + } + status = finish_operation(*owner, noto::result_from(code, owner->database, "execute statement")); + co_return std::unexpected(status.error()); + } +} + +auto noto::detail::statement_core::async_finalize() -> boost::asio::awaitable> { + auto owner = connection; + if (!owner) { + co_return invalid_statement(); + } + co_await boost::asio::dispatch(owner->strand, boost::asio::use_awaitable); + if (!statement_is_open(*this)) { + co_return invalid_statement(); + } + auto ready = begin_operation(*owner, "finalize statement"); + if (!ready) { + co_return ready; + } + auto* current = std::exchange(statement, nullptr); + finalized.store(true, std::memory_order_release); + auto status = noto::result_from(sqlite3_finalize(current), owner->database, "finalize statement"); + untrack_statement(*this); + co_return finish_operation(*owner, status); +} + +noto::detail::transaction_state::transaction_state(std::shared_ptr core_value, std::function on_finish_value) : core{std::move(core_value)}, on_finish{std::move(on_finish_value)}, active{true} {} + +auto noto::detail::transaction_state::finish() noexcept -> void { + if (!on_finish) { + return; + } + auto callback = std::move(on_finish); + on_finish = {}; + callback(); +} diff --git a/src/detail/errors.cpp b/src/detail/errors.cpp new file mode 100644 index 0000000..f5356e3 --- /dev/null +++ b/src/detail/errors.cpp @@ -0,0 +1,26 @@ +#include + +#include +#include +#include +#include +#include + +#include + +auto noto::detail::make_error(std::int32_t code, std::string message, modulus::source_location location) -> modulus::error { + return modulus::make_error("noto", code, std::move(message), location); +} + +auto noto::detail::fail_void(modulus::error error) -> std::expected { + return std::unexpected(std::move(error)); +} + +auto noto::detail::fail_void(std::int32_t code, std::string message, modulus::source_location location) -> std::expected { + return noto::detail::fail_void(noto::detail::make_error(code, std::move(message), location)); +} + +auto noto::detail::fail_void(std::expected const& result) -> std::expected { + assert(!result); + return noto::detail::fail_void(result.error()); +} diff --git a/src/detail/sql.cpp b/src/detail/sql.cpp new file mode 100644 index 0000000..e5d86c7 --- /dev/null +++ b/src/detail/sql.cpp @@ -0,0 +1,32 @@ +#include + +#include +#include +#include +#include + +#include + +#include + +auto noto::detail::quote_sql_literal(std::string_view value, std::string_view label, std::bool_t allow_empty) -> std::expected { + if (value.empty() && !allow_empty) { + return noto::detail::fail(noto::detail::error_invalid_argument, std::string{label} + " must not be empty"); + } + auto quoted = std::string{"'"}; + quoted.reserve((value.size() * 2U) + 2U); + for (auto character : value) { + switch (character) { + case '\0': + return noto::detail::fail(noto::detail::error_invalid_argument, std::string{label} + " must not contain null bytes"); + case '\'': + quoted += "''"; + break; + default: + quoted += character; + break; + } + } + quoted += "'"; + return modulus::success(std::move(quoted)); +} diff --git a/src/errors.cpp b/src/errors.cpp new file mode 100644 index 0000000..e21f22b --- /dev/null +++ b/src/errors.cpp @@ -0,0 +1,79 @@ +#include + +#include +#include +#include +#include + +#include + +#include + +#include + +namespace { + +auto code_from(std::int32_t code) -> std::int32_t { + switch (code & 0xff) { + case SQLITE_OK: + case SQLITE_ROW: + case SQLITE_DONE: + return 0; + case SQLITE_MISUSE: + case SQLITE_RANGE: + return noto::detail::error_invalid_argument; + case SQLITE_NOTFOUND: + case SQLITE_EMPTY: + return noto::detail::error_not_found; + case SQLITE_BUSY: + case SQLITE_LOCKED: + return noto::detail::error_unavailable; + case SQLITE_INTERRUPT: + return noto::detail::error_cancelled; + case SQLITE_AUTH: + case SQLITE_PERM: + case SQLITE_READONLY: + return noto::detail::error_permission_denied; + case SQLITE_CONSTRAINT: + return noto::detail::error_constraint_violation; + case SQLITE_ERROR: + return noto::detail::error_syntax_error; + case SQLITE_CORRUPT: + case SQLITE_NOTADB: + return noto::detail::error_data_loss; + case SQLITE_IOERR: + case SQLITE_CANTOPEN: + return noto::detail::error_unavailable; + case SQLITE_NOMEM: + return noto::detail::error_resource_exhausted; + default: + return noto::detail::error_internal; + } +} + +auto message_from(std::int32_t code, sqlite3* database, std::string_view operation) -> std::string { + auto message = std::string{}; + if (!operation.empty()) { + message += operation; + message += ": "; + } + if (database != nullptr) { + message += sqlite3_errmsg(database); + } else { + message += sqlite3_errstr(code); + } + return message; +} + +} + +auto noto::error_from(std::int32_t code, sqlite3* database, std::string_view operation) -> modulus::error { + return noto::detail::make_error(code_from(code), message_from(code, database, operation)); +} + +auto noto::result_from(std::int32_t code, sqlite3* database, std::string_view operation) -> std::expected { + if (code == SQLITE_OK || code == SQLITE_ROW || code == SQLITE_DONE) { + return {}; + } + return noto::detail::fail_void(noto::error_from(code, database, operation)); +} diff --git a/src/migration.cpp b/src/migration.cpp new file mode 100644 index 0000000..8d10114 --- /dev/null +++ b/src/migration.cpp @@ -0,0 +1,322 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +auto invalid(std::string message) -> std::expected { + return noto::detail::fail_void(noto::detail::error_invalid_argument, std::move(message)); +} + +auto internal(std::string message) -> std::expected { + return noto::detail::fail_void(noto::detail::error_internal, std::move(message)); +} + +auto contains_null(std::string_view value) -> std::bool_t { + return value.find('\0') != std::string_view::npos; +} + +auto qualified_table(noto::table_name const& table) -> std::expected { + auto name = noto::quote_identifier(table.name); + if (!name) { + return std::unexpected(name.error()); + } + if (table.database.empty()) { + return *name; + } + auto database = noto::quote_identifier(table.database); + if (!database) { + return std::unexpected(database.error()); + } + return *database + "." + *name; +} + +auto migration_table_sql(noto::migration_options const& options) -> std::expected { + auto table = qualified_table(options.table); + if (!table) { + return std::unexpected(table.error()); + } + return "CREATE TABLE IF NOT EXISTS " + *table + " (\"id\" TEXT NOT NULL PRIMARY KEY, " "\"description\" TEXT NOT NULL, " "\"checksum\" TEXT NOT NULL, " "\"applied_at\" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP) STRICT"; +} + +auto select_records_sql(noto::migration_options const& options) -> std::expected { + auto table = qualified_table(options.table); + if (!table) { + return std::unexpected(table.error()); + } + return "SELECT \"id\", \"description\", \"checksum\", \"applied_at\" FROM " + *table + " ORDER BY \"id\""; +} + +auto insert_record_sql(noto::migration_options const& options, noto::migration const& migration) -> std::expected { + auto table = qualified_table(options.table); + if (!table) { + return std::unexpected(table.error()); + } + auto id = noto::detail::quote_sql_literal(migration.id, "migration id"); + if (!id) { + return std::unexpected(id.error()); + } + auto description = noto::detail::quote_sql_literal(migration.description, "migration description"); + if (!description) { + return std::unexpected(description.error()); + } + auto checksum = noto::detail::quote_sql_literal(migration.checksum, "migration checksum"); + if (!checksum) { + return std::unexpected(checksum.error()); + } + return "INSERT INTO " + *table + " (\"id\", \"description\", \"checksum\") VALUES (" + *id + ", " + *description + ", " + *checksum + ")"; +} + +auto delete_record_sql(noto::migration_options const& options, std::string_view id_value) -> std::expected { + auto table = qualified_table(options.table); + if (!table) { + return std::unexpected(table.error()); + } + auto id = noto::detail::quote_sql_literal(id_value, "migration id"); + if (!id) { + return std::unexpected(id.error()); + } + return "DELETE FROM " + *table + " WHERE \"id\" = " + *id; +} + +auto validate_and_sort(std::vector migrations, std::bool_t require_up) -> std::expected, modulus::error> { + auto ids = std::set{}; + for (auto const& migration : migrations) { + if (migration.id.empty()) { + return std::unexpected(invalid("migration id must not be empty").error()); + } + if (contains_null(migration.id)) { + return std::unexpected(invalid("migration id must not contain null bytes").error()); + } + if (!ids.insert(migration.id).second) { + return std::unexpected(invalid("migration ids must be unique").error()); + } + if (require_up && !migration.up) { + return std::unexpected(invalid("migration up callback must be set").error()); + } + if (contains_null(migration.description)) { + return std::unexpected(invalid("migration description must not contain null bytes").error()); + } + if (contains_null(migration.checksum)) { + return std::unexpected(invalid("migration checksum must not contain null bytes").error()); + } + } + std::ranges::sort(migrations, {}, ¬o::migration::id); + return migrations; +} + +auto as_string(noto::sqlite_value const& value) -> std::string { + if (auto text = std::get_if(&value.value)) { + return *text; + } + if (auto integer = std::get_if(&value.value)) { + return std::to_string(*integer); + } + return {}; +} + +auto execute_status(noto::connection& connection, std::string sql) -> boost::asio::awaitable> { + auto result = co_await connection.async_execute(std::move(sql)); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return std::expected{}; +} + +auto ensure_migration_table(noto::connection& connection, noto::migration_options const& options) -> boost::asio::awaitable> { + auto sql = migration_table_sql(options); + if (!sql) { + co_return std::unexpected(sql.error()); + } + co_return co_await execute_status(connection, *sql); +} + +auto load_records(noto::connection& connection, noto::migration_options const& options) -> boost::asio::awaitable, modulus::error>> { + auto sql = select_records_sql(options); + if (!sql) { + co_return std::unexpected(sql.error()); + } + auto result = co_await connection.async_execute(*sql); + if (!result) { + co_return std::unexpected(result.error()); + } + auto records = std::vector{}; + records.reserve(result->rows.size()); + for (auto const& row : result->rows) { + if (row.size() < 4) { + co_return std::unexpected(internal("migration table returned an incomplete row").error()); + } + records.push_back({ + .id = as_string(row.at(0)), + .description = as_string(row.at(1)), + .checksum = as_string(row.at(2)), + .applied_at = as_string(row.at(3)), + }); + } + co_return records; +} + +auto applied_record(std::vector const& records, std::string const& id) -> noto::migration_record const* { + for (auto const& record : records) { + if (record.id == id) { + return &record; + } + } + return nullptr; +} + +auto rollback_and_return(noto::transaction& transaction, std::expected status) -> boost::asio::awaitable> { + auto rollback = co_await transaction.async_rollback(); + if (!rollback) { + co_return rollback; + } + co_return status; +} + +auto rollback_and_return(noto::transaction& transaction, modulus::error error) -> boost::asio::awaitable> { + co_return co_await rollback_and_return(transaction, noto::detail::fail_void(std::move(error))); +} + +} + +noto::migrator::migrator(noto::connection& connection, noto::migration_options options) : connection_{&connection}, options_{std::move(options)} {} + +auto noto::migrator::async_applied_migrations() -> boost::asio::awaitable, modulus::error>> { + if (connection_ == nullptr) { + co_return std::unexpected(invalid("migrator connection is not valid").error()); + } + auto ensured = co_await ensure_migration_table(*connection_, options_); + if (!ensured) { + co_return std::unexpected(ensured.error()); + } + co_return co_await load_records(*connection_, options_); +} + +auto noto::migrator::async_migrate(std::vector migrations) -> boost::asio::awaitable> { + modulus::log_debug("noto migration run requested for " + std::to_string(migrations.size()) + " migrations"); + if (connection_ == nullptr) { + co_return invalid("migrator connection is not valid"); + } + auto sorted = validate_and_sort(std::move(migrations), true); + if (!sorted) { + co_return std::unexpected(sorted.error()); + } + auto ensured = co_await ensure_migration_table(*connection_, options_); + if (!ensured) { + co_return ensured; + } + auto transaction_result = co_await connection_->async_begin_transaction(noto::transaction_mode::IMMEDIATE); + if (!transaction_result) { + co_return std::unexpected(transaction_result.error()); + } + auto transaction = std::move(*transaction_result); + auto records = co_await load_records(*connection_, options_); + if (!records) { + co_return co_await rollback_and_return(transaction, records.error()); + } + for (auto const& migration : *sorted) { + auto const* applied = applied_record(*records, migration.id); + if (applied != nullptr) { + if (applied->checksum != migration.checksum) { + co_return co_await rollback_and_return(transaction, invalid("applied migration checksum mismatch: " + migration.id)); + } + modulus::log_trace("noto migration already applied: " + migration.id); + continue; + } + assert(static_cast(migration.up)); + modulus::log_debug("noto applying migration: " + migration.id); + auto migrated = co_await migration.up(*connection_); + if (!migrated) { + co_return co_await rollback_and_return(transaction, migrated); + } + auto insert_sql = insert_record_sql(options_, migration); + if (!insert_sql) { + co_return co_await rollback_and_return(transaction, insert_sql.error()); + } + auto inserted = co_await execute_status(*connection_, *insert_sql); + if (!inserted) { + co_return co_await rollback_and_return(transaction, inserted); + } + records->push_back({ + .id = migration.id, + .description = migration.description, + .checksum = migration.checksum, + }); + } + co_return co_await transaction.async_commit(); +} + +auto noto::migrator::async_rollback(std::vector migrations, std::string target_id) -> boost::asio::awaitable> { + modulus::log_debug("noto migration rollback requested"); + if (connection_ == nullptr) { + co_return invalid("migrator connection is not valid"); + } + auto sorted = validate_and_sort(std::move(migrations), false); + if (!sorted) { + co_return std::unexpected(sorted.error()); + } + auto by_id = std::map{}; + for (auto& migration : *sorted) { + by_id.emplace(migration.id, std::move(migration)); + } + auto ensured = co_await ensure_migration_table(*connection_, options_); + if (!ensured) { + co_return ensured; + } + auto transaction_result = co_await connection_->async_begin_transaction(noto::transaction_mode::IMMEDIATE); + if (!transaction_result) { + co_return std::unexpected(transaction_result.error()); + } + auto transaction = std::move(*transaction_result); + auto records = co_await load_records(*connection_, options_); + if (!records) { + co_return co_await rollback_and_return(transaction, records.error()); + } + std::ranges::sort(*records, std::greater<>{}, ¬o::migration_record::id); + for (auto const& record : *records) { + if (!target_id.empty() && record.id <= target_id) { + continue; + } + auto found = by_id.find(record.id); + if (found == by_id.end()) { + co_return co_await rollback_and_return(transaction, invalid("applied migration is missing from rollback set: " + record.id)); + } + if (!found->second.down) { + co_return co_await rollback_and_return(transaction, invalid("migration does not define a down callback: " + record.id)); + } + auto reverted = co_await found->second.down(*connection_); + if (!reverted) { + co_return co_await rollback_and_return(transaction, reverted); + } + auto delete_sql = delete_record_sql(options_, record.id); + if (!delete_sql) { + co_return co_await rollback_and_return(transaction, delete_sql.error()); + } + auto deleted = co_await execute_status(*connection_, *delete_sql); + if (!deleted) { + co_return co_await rollback_and_return(transaction, deleted); + } + } + co_return co_await transaction.async_commit(); +} diff --git a/src/schema.cpp b/src/schema.cpp new file mode 100644 index 0000000..0d6ceb0 --- /dev/null +++ b/src/schema.cpp @@ -0,0 +1,523 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace { + +auto invalid_sql(std::string message) -> std::expected { + return noto::detail::fail(noto::detail::error_invalid_argument, std::move(message)); +} + +template +auto invalid_value(std::string message) -> std::expected { + return noto::detail::fail(noto::detail::error_invalid_argument, std::move(message)); +} + +auto contains_null(std::string_view value) -> std::bool_t { + return std::ranges::find(value, '\0') != value.end(); +} + +auto validate_identifier(std::string_view identifier) -> std::expected { + if (identifier.empty()) { + return noto::detail::fail_void(noto::detail::error_invalid_argument, "database identifier must not be empty"); + } + if (contains_null(identifier)) { + return noto::detail::fail_void(noto::detail::error_invalid_argument, "database identifier must not contain null bytes"); + } + return {}; +} + +auto validate_fragment(std::string_view value, std::string_view label) -> std::expected { + if (value.empty()) { + return noto::detail::fail_void(noto::detail::error_invalid_argument, std::string{label} + " must not be empty"); + } + if (contains_null(value)) { + return noto::detail::fail_void(noto::detail::error_invalid_argument, std::string{label} + " must not contain null bytes"); + } + return {}; +} + +auto qualified_table(noto::table_name const& table) -> std::expected { + auto name = noto::quote_identifier(table.name); + if (!name) { + return std::unexpected(name.error()); + } + if (table.database.empty()) { + return *name; + } + auto database = noto::quote_identifier(table.database); + if (!database) { + return std::unexpected(database.error()); + } + return *database + "." + *name; +} + +auto generated_storage_sql(noto::generated_column_storage storage) -> std::string_view { + switch (storage) { + case noto::generated_column_storage::VIRTUAL: + return "VIRTUAL"; + case noto::generated_column_storage::STORED: + return "STORED"; + } + return "VIRTUAL"; +} + +auto reference_action_sql(noto::reference_action action) -> std::string_view { + switch (action) { + case noto::reference_action::RESTRICT: + return "RESTRICT"; + case noto::reference_action::CASCADE: + return "CASCADE"; + case noto::reference_action::SET_NULL: + return "SET NULL"; + case noto::reference_action::NO_ACTION: + return "NO ACTION"; + case noto::reference_action::SET_DEFAULT: + return "SET DEFAULT"; + } + return "RESTRICT"; +} + +auto append_identifier_list(std::ostringstream& sql, std::vector const& identifiers) -> std::expected { + if (identifiers.empty()) { + return noto::detail::fail_void(noto::detail::error_invalid_argument, "column list must not be empty"); + } + for (auto index = std::size_t{0}; index < identifiers.size(); ++index) { + auto identifier = noto::quote_identifier(identifiers[index]); + if (!identifier) { + return std::unexpected(identifier.error()); + } + if (index != 0) { + sql << ", "; + } + sql << *identifier; + } + return {}; +} + +auto append_constraint_name(std::ostringstream& sql, std::string const& name) -> std::expected { + if (name.empty()) { + return {}; + } + auto quoted = noto::quote_identifier(name); + if (!quoted) { + return std::unexpected(quoted.error()); + } + sql << "CONSTRAINT " << *quoted << " "; + return {}; +} + +auto column_sql(noto::column_definition const& column) -> std::expected { + auto name = noto::quote_identifier(column.name); + if (!name) { + return std::unexpected(name.error()); + } + auto type_status = validate_fragment(column.type.sql(), "column type"); + if (!type_status) { + return std::unexpected(type_status.error()); + } + if (column.auto_increment && (!column.primary_key || column.type.sql() != "INTEGER")) { + return invalid_sql("SQLite autoincrement columns must be INTEGER PRIMARY KEY columns"); + } + auto sql = std::ostringstream{}; + sql << *name << " " << column.type.sql(); + if (column.generated) { + if (column.auto_increment) { + return invalid_sql("generated columns must not be auto increment columns"); + } + if (column.default_value) { + return invalid_sql("generated columns must not define a default value"); + } + auto expression_status = validate_fragment(column.generated->expression.value(), "generated column expression"); + if (!expression_status) { + return std::unexpected(expression_status.error()); + } + sql << " GENERATED ALWAYS AS (" << column.generated->expression.value() << ") " << generated_storage_sql(column.generated->storage); + } + if (column.auto_increment) { + sql << " PRIMARY KEY AUTOINCREMENT"; + return sql.str(); + } + if (!column.nullable) { + sql << " NOT NULL"; + } + if (column.default_value) { + auto default_status = validate_fragment(column.default_value->sql(), "column default expression"); + if (!default_status) { + return std::unexpected(default_status.error()); + } + sql << " DEFAULT " << column.default_value->sql(); + } + if (column.primary_key) { + sql << " PRIMARY KEY"; + } + return sql.str(); +} + +auto primary_key_sql(std::vector const& columns) -> std::expected { + auto sql = std::ostringstream{}; + sql << "PRIMARY KEY ("; + auto status = append_identifier_list(sql, columns); + if (!status) { + return std::unexpected(status.error()); + } + sql << ")"; + return sql.str(); +} + +auto unique_constraint_sql(noto::unique_constraint_definition const& definition) -> std::expected { + auto sql = std::ostringstream{}; + auto name_status = append_constraint_name(sql, definition.name); + if (!name_status) { + return std::unexpected(name_status.error()); + } + sql << "UNIQUE ("; + auto columns = append_identifier_list(sql, definition.columns); + if (!columns) { + return std::unexpected(columns.error()); + } + sql << ")"; + return sql.str(); +} + +auto check_constraint_sql(noto::check_constraint_definition const& definition) -> std::expected { + if (!definition.enforced) { + return invalid_sql("SQLite check constraints cannot be marked not enforced"); + } + auto expression_status = validate_fragment(definition.expression.value(), "check constraint expression"); + if (!expression_status) { + return std::unexpected(expression_status.error()); + } + auto sql = std::ostringstream{}; + auto name_status = append_constraint_name(sql, definition.name); + if (!name_status) { + return std::unexpected(name_status.error()); + } + sql << "CHECK (" << definition.expression.value() << ")"; + return sql.str(); +} + +auto foreign_key_sql(noto::foreign_key_definition const& definition) -> std::expected { + auto target = qualified_table(definition.referenced_table); + if (!target) { + return std::unexpected(target.error()); + } + auto sql = std::ostringstream{}; + auto name_status = append_constraint_name(sql, definition.name); + if (!name_status) { + return std::unexpected(name_status.error()); + } + sql << "FOREIGN KEY ("; + auto columns = append_identifier_list(sql, definition.columns); + if (!columns) { + return std::unexpected(columns.error()); + } + sql << ") REFERENCES " << *target << " ("; + auto referenced_columns = append_identifier_list(sql, definition.referenced_columns); + if (!referenced_columns) { + return std::unexpected(referenced_columns.error()); + } + sql << ")"; + if (definition.on_delete) { + sql << " ON DELETE " << reference_action_sql(*definition.on_delete); + } + if (definition.on_update) { + sql << " ON UPDATE " << reference_action_sql(*definition.on_update); + } + return sql.str(); +} + +auto append_table_options(std::ostringstream& sql, noto::table_options const& options) -> void { + auto first = true; + auto append = [&sql, &first](std::string_view value) { + sql << (first ? " " : ", ") << value; + first = false; + }; + if (options.strict) { + append("STRICT"); + } + if (options.without_rowid) { + append("WITHOUT ROWID"); + } +} + +} + +auto noto::trusted_sql_fragment::unsafe(std::string sql) -> std::expected { + auto status = validate_fragment(sql, "trusted SQL fragment"); + if (!status) { + return std::unexpected(status.error()); + } + return noto::trusted_sql_fragment{std::move(sql)}; +} + +auto noto::trusted_sql_fragment::value() const -> std::string const& { + return sql_; +} + +auto noto::trusted_sql_fragment::empty() const -> std::bool_t { + return sql_.empty(); +} + +noto::trusted_sql_fragment::trusted_sql_fragment(std::string sql) : sql_{std::move(sql)} {} + +auto noto::column_type::integer() -> noto::column_type { + return noto::column_type{"INTEGER"}; +} + +auto noto::column_type::bigint() -> noto::column_type { + return noto::column_type{"INTEGER"}; +} + +auto noto::column_type::varchar(std::uint32_t length) -> std::expected { + if (length == 0) { + return invalid_value("VARCHAR length must be positive"); + } + return noto::column_type{"TEXT"}; +} + +auto noto::column_type::text() -> noto::column_type { + return noto::column_type{"TEXT"}; +} + +auto noto::column_type::blob() -> noto::column_type { + return noto::column_type{"BLOB"}; +} + +auto noto::column_type::real() -> noto::column_type { + return noto::column_type{"REAL"}; +} + +auto noto::column_type::boolean() -> noto::column_type { + return noto::column_type{"INTEGER"}; +} + +auto noto::column_type::timestamp() -> noto::column_type { + return noto::column_type{"TEXT"}; +} + +auto noto::column_type::decimal(std::uint32_t precision, std::uint32_t scale) -> std::expected { + if (precision == 0) { + return invalid_value("DECIMAL precision must be positive"); + } + if (scale > precision) { + return invalid_value("DECIMAL scale must be less than or equal to precision"); + } + return noto::column_type{"NUMERIC"}; +} + +auto noto::column_type::trusted(noto::trusted_sql_fragment fragment) -> noto::column_type { + return noto::column_type{fragment.value()}; +} + +auto noto::column_type::sql() const -> std::string const& { + return sql_; +} + +auto noto::column_type::empty() const -> std::bool_t { + return sql_.empty(); +} + +noto::column_type::column_type(std::string sql) : sql_{std::move(sql)} {} + +auto noto::default_value::null_value() -> noto::default_value { + return noto::default_value{"NULL"}; +} + +auto noto::default_value::string(std::string_view value) -> std::expected { + auto literal = noto::detail::quote_sql_literal(value, "column default string"); + if (!literal) { + return std::unexpected(literal.error()); + } + return noto::default_value{*literal}; +} + +auto noto::default_value::integer(std::int64_t value) -> noto::default_value { + return noto::default_value{std::to_string(value)}; +} + +auto noto::default_value::unsigned_integer(std::uint64_t value) -> noto::default_value { + return noto::default_value{std::to_string(value)}; +} + +auto noto::default_value::real(std::float64_t value) -> std::expected { + if (!std::isfinite(value)) { + return invalid_value("column default real value must be finite"); + } + auto sql = std::ostringstream{}; + sql << value; + return noto::default_value{sql.str()}; +} + +auto noto::default_value::boolean(std::bool_t value) -> noto::default_value { + return noto::default_value{value ? "1" : "0"}; +} + +auto noto::default_value::current_timestamp() -> noto::default_value { + return noto::default_value{"CURRENT_TIMESTAMP"}; +} + +auto noto::default_value::trusted_expression(noto::trusted_sql_fragment fragment) -> noto::default_value { + return noto::default_value{fragment.value()}; +} + +auto noto::default_value::sql() const -> std::string const& { + return sql_; +} + +auto noto::default_value::empty() const -> std::bool_t { + return sql_.empty(); +} + +noto::default_value::default_value(std::string sql) : sql_{std::move(sql)} {} + +auto noto::quote_identifier(std::string_view identifier) -> std::expected { + auto valid = validate_identifier(identifier); + if (!valid) { + return std::unexpected(valid.error()); + } + auto quoted = std::string{"\""}; + quoted.reserve(identifier.size() + 2); + for (std::char_t character : identifier) { + if (character == '"') { + quoted += "\"\""; + } else { + quoted += character; + } + } + quoted += "\""; + return quoted; +} + +auto noto::create_table_sql(noto::table_definition const& definition) -> std::expected { + auto table = qualified_table(definition.name); + if (!table) { + return std::unexpected(table.error()); + } + if (definition.columns.empty()) { + return invalid_sql("table must define at least one column"); + } + auto inline_primary_key_count = std::ranges::count_if(definition.columns, [](noto::column_definition const& column) { + return column.primary_key; + }); + if (!definition.primary_key.empty() && inline_primary_key_count != 0) { + return invalid_sql("table must not mix column primary keys with a table primary key"); + } + if (definition.primary_key.empty() && inline_primary_key_count > 1) { + return invalid_sql("table must not define multiple column-level primary keys"); + } + auto definitions = std::vector{}; + for (auto const& column_definition : definition.columns) { + auto column = column_sql(column_definition); + if (!column) { + return std::unexpected(column.error()); + } + definitions.push_back(std::move(*column)); + } + if (!definition.primary_key.empty()) { + auto constraint = primary_key_sql(definition.primary_key); + if (!constraint) { + return std::unexpected(constraint.error()); + } + definitions.push_back(std::move(*constraint)); + } + for (auto const& unique_constraint : definition.unique_constraints) { + auto constraint = unique_constraint_sql(unique_constraint); + if (!constraint) { + return std::unexpected(constraint.error()); + } + definitions.push_back(std::move(*constraint)); + } + for (auto const& check_constraint : definition.check_constraints) { + auto constraint = check_constraint_sql(check_constraint); + if (!constraint) { + return std::unexpected(constraint.error()); + } + definitions.push_back(std::move(*constraint)); + } + for (auto const& foreign_key : definition.foreign_keys) { + auto constraint = foreign_key_sql(foreign_key); + if (!constraint) { + return std::unexpected(constraint.error()); + } + definitions.push_back(std::move(*constraint)); + } + auto sql = std::ostringstream{}; + sql << "CREATE TABLE " << (definition.if_not_exists ? "IF NOT EXISTS " : "") << *table << " ("; + for (auto index = std::size_t{0}; index < definitions.size(); ++index) { + if (index != 0) { + sql << ", "; + } + sql << definitions[index]; + } + sql << ")"; + append_table_options(sql, definition.options); + return sql.str(); +} + +auto noto::drop_table_sql(noto::table_name const& table, std::bool_t if_exists) -> std::expected { + auto name = qualified_table(table); + if (!name) { + return std::unexpected(name.error()); + } + return std::string{"DROP TABLE "} + (if_exists ? "IF EXISTS " : "") + *name; +} + +auto noto::create_index_sql(noto::index_definition const& definition) -> std::expected { + auto table = qualified_table(definition.table); + if (!table) { + return std::unexpected(table.error()); + } + auto index = noto::quote_identifier(definition.name); + if (!index) { + return std::unexpected(index.error()); + } + auto sql = std::ostringstream{}; + sql << "CREATE " << (definition.unique ? "UNIQUE " : "") << "INDEX IF NOT EXISTS "; + if (!definition.table.database.empty()) { + auto database = noto::quote_identifier(definition.table.database); + if (!database) { + return std::unexpected(database.error()); + } + sql << *database << "."; + } + sql << *index << " ON " << *table << " ("; + auto columns = append_identifier_list(sql, definition.columns); + if (!columns) { + return std::unexpected(columns.error()); + } + sql << ")"; + return sql.str(); +} + +auto noto::drop_index_sql(noto::index_definition const& definition) -> std::expected { + auto index = noto::quote_identifier(definition.name); + if (!index) { + return std::unexpected(index.error()); + } + if (definition.table.database.empty()) { + return "DROP INDEX IF EXISTS " + *index; + } + auto database = noto::quote_identifier(definition.table.database); + if (!database) { + return std::unexpected(database.error()); + } + return "DROP INDEX IF EXISTS " + *database + "." + *index; +} + +auto noto::table_exists_sql() -> std::string { + return "SELECT \"name\" FROM sqlite_schema WHERE \"type\" = 'table' AND \"name\" = ?1"; +} diff --git a/src/transaction.cpp b/src/transaction.cpp new file mode 100644 index 0000000..ac07ebc --- /dev/null +++ b/src/transaction.cpp @@ -0,0 +1,111 @@ +#include + +#include +#include +#include +#include +#include + +#include + +#include + +#include + +#include + +namespace { + +auto inactive_transaction() -> std::expected { + return noto::detail::fail_void(noto::detail::error_invalid_argument, "transaction is not active"); +} + +auto async_status(std::expected status) -> boost::asio::awaitable> { + co_return status; +} + +auto finish_started_transaction(std::shared_ptr& state, noto::detail::transaction_result const& result) -> void { + if (result.status) { + state->core.reset(); + state->finish(); + return; + } + if (result.active) { + if (state->detached.load(std::memory_order_acquire)) { + state->core->abandon_transaction(); + state->core.reset(); + state->finish(); + } else { + state->active.store(true, std::memory_order_release); + } + return; + } + state->core.reset(); + state->finish(); +} + +auto async_commit_started_impl(std::shared_ptr state) -> boost::asio::awaitable> { + auto result = co_await state->core->async_commit_transaction(); + finish_started_transaction(state, result); + co_return result.status; +} + +auto async_commit_impl(std::shared_ptr state) -> boost::asio::awaitable> { + modulus::log_debug("noto transaction commit requested"); + if (!state || !state->core || !state->active.exchange(false, std::memory_order_acq_rel)) { + return async_status(inactive_transaction()); + } + state->detached.store(false, std::memory_order_release); + return async_commit_started_impl(std::move(state)); +} + +auto async_rollback_started_impl(std::shared_ptr state) -> boost::asio::awaitable> { + auto result = co_await state->core->async_rollback_transaction(); + finish_started_transaction(state, result); + co_return result.status; +} + +auto async_rollback_impl(std::shared_ptr state) -> boost::asio::awaitable> { + modulus::log_debug("noto transaction rollback requested"); + if (!state || !state->core || !state->active.exchange(false, std::memory_order_acq_rel)) { + return async_status(inactive_transaction()); + } + state->detached.store(false, std::memory_order_release); + return async_rollback_started_impl(std::move(state)); +} + +} + +noto::transaction::transaction(noto::transaction&& other) noexcept : state_{std::move(other.state_)} {} + +noto::transaction::~transaction() { + if (!state_) { + return; + } + if (state_->active.exchange(false, std::memory_order_acq_rel) && state_->core) { + state_->core->abandon_transaction(); + state_->finish(); + return; + } + if (state_->core) { + state_->detached.store(true, std::memory_order_release); + return; + } + state_->finish(); +} + +auto noto::transaction::active() const -> std::bool_t { + return state_ && state_->active.load(std::memory_order_acquire); +} + +auto noto::transaction::async_commit() -> boost::asio::awaitable> { + return async_commit_impl(state_); +} + +auto noto::transaction::async_rollback() -> boost::asio::awaitable> { + return async_rollback_impl(state_); +} + +noto::transaction::transaction(std::shared_ptr state) : state_{std::move(state)} {} + +noto::transaction::transaction(std::shared_ptr core, std::function on_finish) : state_{std::make_shared(std::move(core), std::move(on_finish))} {} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100755 index 0000000..068c277 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,26 @@ +add_executable(noto-test + test.cpp +) +find_package(Threads REQUIRED) +target_include_directories(noto-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries(noto-test PRIVATE + noto::noto + Threads::Threads +) +target_compile_features(noto-test PRIVATE cxx_std_23) +target_compile_definitions(noto-test PRIVATE + $<$:NOTO_EXPECT_DEBUG_ASSERTS> + $<$>:NOTO_EXPECT_NDEBUG> +) +add_test(NAME noto COMMAND noto-test) +set_tests_properties(noto PROPERTIES TIMEOUT 20) +add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} + --test-dir ${CMAKE_BINARY_DIR} + -R ^noto$ + --output-on-failure + --timeout 20 + USES_TERMINAL +) diff --git a/tests/test.cpp b/tests/test.cpp new file mode 100644 index 0000000..d6cc693 --- /dev/null +++ b/tests/test.cpp @@ -0,0 +1,1149 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +BOOST_AUTO_TEST_CASE(noto_asserts_follow_build_type) { +#if defined(NOTO_EXPECT_NDEBUG) && !defined(NDEBUG) + BOOST_FAIL("release-style builds must define NDEBUG"); +#endif +#if defined(NOTO_EXPECT_DEBUG_ASSERTS) && defined(NDEBUG) + BOOST_FAIL("debug builds must not define NDEBUG"); +#endif +} + + +namespace { + +template +auto run(boost::asio::io_context& io, AWAITABLE&& awaitable) { + auto future = boost::asio::co_spawn(io, std::forward(awaitable), boost::asio::use_future); + io.run(); + io.restart(); + return future.get(); +} + +auto temp_database_path(std::string_view name) -> std::string { + auto path = std::filesystem::temp_directory_path() / (std::string{"noto_"} + std::string{name} + "_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".sqlite3"); + return path.string(); +} + +auto remove_database_files(std::string const& path) -> void { + std::filesystem::remove(path); + std::filesystem::remove(path + "-wal"); + std::filesystem::remove(path + "-shm"); + std::filesystem::remove(path + "-journal"); +} + +auto text_value(noto::sqlite_value const& value) -> std::string { + auto text = std::get_if(&value.value); + return text == nullptr ? std::string{} : *text; +} + +auto int_value(noto::sqlite_value const& value) -> std::int64_t { + auto integer = std::get_if(&value.value); + return integer == nullptr ? 0 : *integer; +} + +auto execute_status(noto::connection& connection, std::string sql) -> boost::asio::awaitable> { + auto result = co_await connection.async_execute(std::move(sql)); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return std::expected{}; +} + +auto sqlite_exec_ok(sqlite3* database, std::string const& sql) -> std::bool_t { + auto* error = static_cast(nullptr); + auto code = sqlite3_exec(database, sql.c_str(), nullptr, nullptr, &error); + sqlite3_free(error); + return code == SQLITE_OK; +} + +auto create_sqlite_database(std::string const& path, std::string const& sql) -> std::bool_t { + auto* database = static_cast(nullptr); + if (sqlite3_open_v2(path.c_str(), &database, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nullptr) != SQLITE_OK) { + if (database != nullptr) { + sqlite3_close(database); + } + return false; + } + auto ok = sqlite_exec_ok(database, sql); + return sqlite3_close(database) == SQLITE_OK && ok; +} + +auto write_file(std::string const& path, std::string_view text) -> void { + auto output = std::ofstream{ + path, + std::ios::binary | std::ios::trunc, + }; + output << text; +} + +auto read_file(std::string const& path) -> std::string { + auto input = std::ifstream{ + path, + std::ios::binary, + }; + auto output = std::ostringstream{}; + output << input.rdbuf(); + return output.str(); +} + +auto sqlite_scalar_int64(sqlite3* database, std::string const& sql) -> std::int64_t { + auto* statement = static_cast(nullptr); + if (sqlite3_prepare_v2(database, sql.c_str(), -1, &statement, nullptr) != SQLITE_OK) { + return 0; + } + auto guard = std::unique_ptr{ + statement, + sqlite3_finalize, + }; + if (sqlite3_step(statement) != SQLITE_ROW) { + return 0; + } + return sqlite3_column_int64(statement, 0); +} + +} + +BOOST_AUTO_TEST_CASE(noto_status_defaults_to_ok) { + auto status = std::expected{}; + BOOST_TEST(static_cast(status)); +} + +BOOST_AUTO_TEST_CASE(noto_result_carries_failure_status) { + auto ok = std::expected{}; + BOOST_TEST(static_cast(ok.has_value())); + + auto result = noto::detail::fail(noto::detail::error_invalid_argument, "bad value"); + BOOST_REQUIRE(!result); + BOOST_TEST(static_cast(result.error().code()) == static_cast(noto::detail::error_invalid_argument)); + BOOST_TEST(result.error().message() == "bad value"); +} + +BOOST_AUTO_TEST_CASE(noto_status_maps_sqlite_errors) { + BOOST_TEST(static_cast(noto::error_from(SQLITE_CONSTRAINT).code()) == static_cast(noto::detail::error_constraint_violation)); + BOOST_TEST(static_cast(noto::error_from(SQLITE_ERROR).code()) == static_cast(noto::detail::error_syntax_error)); + BOOST_TEST(static_cast(noto::error_from(SQLITE_BUSY).code()) == static_cast(noto::detail::error_unavailable)); + BOOST_TEST(static_cast(noto::error_from(SQLITE_CORRUPT).code()) == static_cast(noto::detail::error_data_loss)); +} + +BOOST_AUTO_TEST_CASE(noto_config_validates_and_parses_json) { + auto config = noto::connection_config{}; + BOOST_TEST(static_cast(noto::validate(config))); + + config.filename = ""; + BOOST_TEST(!static_cast(noto::validate(config))); + + auto value = boost::json::parse(R"json({ + "databases": [ + { + "name": "main", + "mode": "memory", + "busy_timeout_ms": 250, + "foreign_keys": false, + "pragmas": { + "user_version": 7, + "journal_mode": "MEMORY" + } + } + ] + })json"); + auto parsed = noto::parse_database_configs(value); + BOOST_REQUIRE(parsed); + BOOST_REQUIRE(parsed->size() == 1U); + BOOST_TEST(parsed->at(0).name == "main"); + BOOST_TEST(parsed->at(0).filename == ":memory:"); + BOOST_TEST(static_cast(parsed->at(0).mode) == static_cast(noto::open_mode::MEMORY)); + BOOST_TEST(parsed->at(0).busy_timeout == std::chrono::milliseconds{250}); + BOOST_TEST(!static_cast(parsed->at(0).foreign_keys)); + BOOST_TEST(parsed->at(0).pragmas.size() == 2U); + auto round_trip = noto::parse_database_configs(noto::to_json(*parsed)); + BOOST_REQUIRE(round_trip); + BOOST_REQUIRE(round_trip->size() == 1U); + BOOST_TEST(round_trip->at(0).name == "main"); + BOOST_TEST(static_cast(round_trip->at(0).mode) == static_cast(noto::open_mode::MEMORY)); + BOOST_TEST(round_trip->at(0).pragmas.size() == 2U); + + auto bad = noto::parse_database_configs(boost::json::parse(R"json({"databases":[{"name":"bad","filename":":memory:","mode":"server"}]})json")); + BOOST_TEST(!static_cast(bad.has_value())); + + auto missing_file = noto::parse_database_configs(boost::json::parse(R"json({"databases":[{"name":"bad"}]})json")); + BOOST_TEST(!static_cast(missing_file.has_value())); + + config.filename = ":memory:"; + config.busy_timeout = std::chrono::milliseconds{static_cast(std::numeric_limits::max()) + 1}; + BOOST_TEST(!static_cast(noto::validate(config))); + + auto oversized_timeout_json = std::string{R"json({"databases":[{"name":"bad","filename":":memory:","busy_timeout_ms":)json"} + + std::to_string(static_cast(std::numeric_limits::max()) + 1) + + R"json(}]})json"; + auto oversized_timeout = noto::parse_database_configs(boost::json::parse(oversized_timeout_json)); + BOOST_TEST(!static_cast(oversized_timeout.has_value())); + + auto unknown_root = noto::parse_database_configs(boost::json::parse(R"json({"databases":[],"databasez":[]})json")); + BOOST_REQUIRE(!unknown_root); + BOOST_TEST(unknown_root.error().describe().find("config root has unknown key: databasez") != std::string::npos); + + auto unknown_database = noto::parse_database_configs(boost::json::parse(R"json({"databases":[{"name":"bad","filename":":memory:","busy_timout_ms":250}]})json")); + BOOST_REQUIRE(!unknown_database); + BOOST_TEST(unknown_database.error().describe().find("database has unknown key: busy_timout_ms") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(noto_schema_quotes_identifiers_and_builds_sqlite_table_sql) { + auto quoted = noto::quote_identifier("with\"quote"); + BOOST_REQUIRE(quoted); + BOOST_TEST(*quoted == "\"with\"\"quote\""); + + auto email_type = noto::column_type::varchar(255); + BOOST_REQUIRE(email_type); + auto sql = noto::create_table_sql({ + .name = { + .name = "users", + }, + .columns = { + { + .name = "id", + .type = noto::column_type::integer(), + .nullable = false, + .auto_increment = true, + .primary_key = true, + }, + { + .name = "email", + .type = *email_type, + .nullable = false, + }, + { + .name = "created_at", + .type = noto::column_type::timestamp(), + .nullable = false, + .default_value = noto::default_value::current_timestamp(), + }, + }, + .options = { + .strict = true, + }, + }); + + BOOST_REQUIRE(sql); + BOOST_TEST(*sql == "CREATE TABLE IF NOT EXISTS \"users\" (\"id\" INTEGER PRIMARY KEY AUTOINCREMENT, \"email\" TEXT NOT NULL, \"created_at\" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP) STRICT"); +} + +BOOST_AUTO_TEST_CASE(noto_schema_builds_constraints_generated_columns_and_options) { + auto expression = noto::trusted_sql_fragment::unsafe("lower(\"email\")"); + BOOST_REQUIRE(expression); + auto check = noto::trusted_sql_fragment::unsafe("\"email\" <> ''"); + BOOST_REQUIRE(check); + auto sql = noto::create_table_sql({ + .name = { + .name = "accounts", + }, + .columns = { + { + .name = "tenant_id", + .type = noto::column_type::integer(), + .nullable = false, + }, + { + .name = "id", + .type = noto::column_type::integer(), + .nullable = false, + }, + { + .name = "email", + .type = noto::column_type::text(), + .nullable = false, + }, + { + .name = "email_key", + .type = noto::column_type::text(), + .nullable = false, + .generated = noto::generated_column_definition{ + .expression = *expression, + .storage = noto::generated_column_storage::STORED, + }, + }, + }, + .primary_key = { + "tenant_id", + "id", + }, + .unique_constraints = { + { + .name = "uq_accounts_email", + .columns = { + "tenant_id", + "email_key", + }, + }, + }, + .check_constraints = { + { + .name = "ck_accounts_email", + .expression = *check, + }, + }, + .foreign_keys = { + { + .name = "fk_accounts_tenant", + .columns = { + "tenant_id", + }, + .referenced_table = { + .name = "tenants", + }, + .referenced_columns = { + "id", + }, + .on_delete = noto::reference_action::CASCADE, + }, + }, + .options = { + .without_rowid = true, + }, + }); + + BOOST_REQUIRE(sql); + BOOST_TEST(*sql == "CREATE TABLE IF NOT EXISTS \"accounts\" (\"tenant_id\" INTEGER NOT NULL, \"id\" INTEGER NOT NULL, \"email\" TEXT NOT NULL, \"email_key\" TEXT GENERATED ALWAYS AS (lower(\"email\")) STORED NOT NULL, PRIMARY KEY (\"tenant_id\", \"id\"), CONSTRAINT \"uq_accounts_email\" UNIQUE (\"tenant_id\", \"email_key\"), CONSTRAINT \"ck_accounts_email\" CHECK (\"email\" <> ''), CONSTRAINT \"fk_accounts_tenant\" FOREIGN KEY (\"tenant_id\") REFERENCES \"tenants\" (\"id\") ON DELETE CASCADE) WITHOUT ROWID"); +} + +BOOST_AUTO_TEST_CASE(noto_connection_executes_queries_and_prepared_statements) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("test"), + }; + + auto connected = run(io, connection.async_connect()); + BOOST_TEST(static_cast(connected)); + BOOST_TEST(static_cast(connection.state()) == static_cast(noto::connection_state::CONNECTED)); + + auto created = run(io, connection.async_execute("CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)")); + BOOST_REQUIRE(created); + + auto statement = run(io, connection.async_prepare("INSERT INTO items(value) VALUES(?1)")); + BOOST_REQUIRE(statement); + auto inserted = run(io, statement->async_execute({ + noto::sqlite_value{"alpha"}, + })); + BOOST_REQUIRE(inserted); + BOOST_TEST(inserted->changes == 1); + + auto selected = run(io, connection.async_execute("SELECT value FROM items WHERE id = ?1", { + noto::sqlite_value{inserted->last_insert_rowid}, + })); + BOOST_REQUIRE(selected); + BOOST_REQUIRE(selected->rows.size() == 1U); + BOOST_TEST(text_value(selected->rows.at(0).at(0)) == "alpha"); + + auto finalized = run(io, statement->async_finalize()); + BOOST_TEST(static_cast(finalized)); + auto closed = run(io, connection.async_close()); + BOOST_TEST(static_cast(closed)); +} + +BOOST_AUTO_TEST_CASE(noto_close_with_live_statement_is_recoverable) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("live_statement"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + + auto statement = run(io, connection.async_prepare("SELECT 1")); + BOOST_REQUIRE(statement); + auto blocked = run(io, connection.async_close()); + BOOST_TEST(!static_cast(blocked)); + BOOST_TEST(static_cast(blocked.error().code()) == static_cast(noto::detail::error_unavailable)); + BOOST_TEST(static_cast(connection.state()) == static_cast(noto::connection_state::CONNECTED)); + + BOOST_TEST(static_cast(run(io, statement->async_finalize()))); + BOOST_TEST(static_cast(run(io, connection.async_close()))); +} + +BOOST_AUTO_TEST_CASE(noto_close_rolls_back_active_transaction) { + auto io = boost::asio::io_context{}; + auto path = temp_database_path("close_active_transaction"); + auto connection = noto::connection{ + io.get_executor(), + noto::file_connection_config(path), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value TEXT NOT NULL)"))); + + { + auto transaction = run(io, connection.async_begin_transaction()); + BOOST_REQUIRE(transaction); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('transient')"))); + BOOST_TEST(static_cast(run(io, connection.async_close()))); + } + + auto reopened = noto::connection{ + io.get_executor(), + noto::file_connection_config(path), + }; + BOOST_TEST(static_cast(run(io, reopened.async_connect()))); + auto count = run(io, reopened.async_execute("SELECT COUNT(*) FROM items")); + BOOST_REQUIRE(count); + BOOST_TEST(int_value(count->rows.at(0).at(0)) == 0); + BOOST_TEST(static_cast(run(io, reopened.async_close()))); + remove_database_files(path); +} + +BOOST_AUTO_TEST_CASE(noto_async_methods_capture_state_from_temporary_wrappers) { + auto io = boost::asio::io_context{}; + + auto connect_future = boost::asio::co_spawn( + io, + noto::connection{ + io.get_executor(), + noto::memory_connection_config("temporary_connection"), + }.async_connect(), + boost::asio::use_future + ); + io.run(); + io.restart(); + BOOST_TEST(static_cast(connect_future.get())); + + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("temporary_wrappers"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value TEXT NOT NULL)"))); + + auto statement = run(io, connection.async_prepare("SELECT 1")); + BOOST_REQUIRE(statement); + auto finalize_future = boost::asio::co_spawn(io, noto::statement{std::move(*statement)}.async_finalize(), boost::asio::use_future); + io.run(); + io.restart(); + BOOST_TEST(static_cast(finalize_future.get())); + + auto rolled_back = run(io, connection.async_begin_transaction()); + BOOST_REQUIRE(rolled_back); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('rolled back')"))); + auto rollback_future = boost::asio::co_spawn(io, noto::transaction{std::move(*rolled_back)}.async_rollback(), boost::asio::use_future); + io.run(); + io.restart(); + BOOST_TEST(static_cast(rollback_future.get())); + + auto committed = run(io, connection.async_begin_transaction()); + BOOST_REQUIRE(committed); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('committed')"))); + auto commit_future = boost::asio::co_spawn(io, noto::transaction{std::move(*committed)}.async_commit(), boost::asio::use_future); + io.run(); + io.restart(); + BOOST_TEST(static_cast(commit_future.get())); + + auto count = run(io, connection.async_execute("SELECT COUNT(*) FROM items")); + BOOST_REQUIRE(count); + BOOST_TEST(int_value(count->rows.at(0).at(0)) == 1); + BOOST_TEST(static_cast(run(io, connection.async_close()))); +} + +BOOST_AUTO_TEST_CASE(noto_detached_transaction_rolls_back_when_commit_cannot_start) { + auto io = boost::asio::io_context{}; + auto export_path = temp_database_path("detached_transaction_export"); + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("detached_transaction"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value INTEGER NOT NULL)"))); + + auto transaction = run(io, connection.async_begin_transaction()); + BOOST_REQUIRE(transaction); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES(1), (2), (3), (4), (5)"))); + + auto options = noto::export_options{}; + options.rows_per_yield = 1; + auto export_future = boost::asio::co_spawn(io, connection.async_export_sql_to_file(export_path, options), boost::asio::use_future); + auto commit_future = boost::asio::co_spawn(io, noto::transaction{std::move(*transaction)}.async_commit(), boost::asio::use_future); + io.run(); + io.restart(); + + BOOST_TEST(static_cast(export_future.get())); + auto commit = commit_future.get(); + BOOST_TEST(static_cast(commit.error().code()) == static_cast(noto::detail::error_unavailable)); + + auto count = run(io, connection.async_execute("SELECT COUNT(*) FROM items")); + BOOST_REQUIRE(count); + BOOST_TEST(int_value(count->rows.at(0).at(0)) == 0); + BOOST_TEST(static_cast(run(io, connection.async_close()))); + std::filesystem::remove(export_path); +} + +BOOST_AUTO_TEST_CASE(noto_statement_destructor_cleanup_allows_close) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("statement_destructor"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + + { + auto statement = run(io, connection.async_prepare("SELECT 1")); + BOOST_REQUIRE(statement); + } + + BOOST_TEST(static_cast(run(io, connection.async_close()))); +} + +BOOST_AUTO_TEST_CASE(noto_transactions_commit_and_rollback) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config(), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value TEXT NOT NULL)"))); + + { + auto transaction = run(io, connection.async_begin_transaction()); + BOOST_REQUIRE(transaction); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('rolled back')"))); + BOOST_TEST(static_cast(run(io, transaction->async_rollback()))); + } + auto count = run(io, connection.async_execute("SELECT COUNT(*) FROM items")); + BOOST_REQUIRE(count); + BOOST_TEST(int_value(count->rows.at(0).at(0)) == 0); + + { + auto transaction = run(io, connection.async_begin_transaction()); + BOOST_REQUIRE(transaction); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('committed')"))); + BOOST_TEST(static_cast(run(io, transaction->async_commit()))); + } + count = run(io, connection.async_execute("SELECT COUNT(*) FROM items")); + BOOST_REQUIRE(count); + BOOST_TEST(int_value(count->rows.at(0).at(0)) == 1); +} + +BOOST_AUTO_TEST_CASE(noto_failed_commit_remains_rollbackable) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("failed_commit"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE parents(id INTEGER PRIMARY KEY)"))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE children(parent_id INTEGER NOT NULL, FOREIGN KEY(parent_id) REFERENCES parents(id) DEFERRABLE INITIALLY DEFERRED)"))); + + auto transaction = run(io, connection.async_begin_transaction(noto::transaction_mode::DEFERRED)); + BOOST_REQUIRE(transaction); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO children(parent_id) VALUES(42)"))); + + auto committed = run(io, transaction->async_commit()); + BOOST_TEST(!static_cast(committed)); + BOOST_TEST(static_cast(committed.error().code()) == static_cast(noto::detail::error_constraint_violation)); + BOOST_TEST(static_cast(transaction->active())); + BOOST_TEST(static_cast(run(io, transaction->async_rollback()))); + + auto count = run(io, connection.async_execute("SELECT COUNT(*) FROM children")); + BOOST_REQUIRE(count); + BOOST_TEST(int_value(count->rows.at(0).at(0)) == 0); + + auto next = run(io, connection.async_begin_transaction()); + BOOST_REQUIRE(next); + BOOST_TEST(static_cast(run(io, next->async_rollback()))); + BOOST_TEST(static_cast(run(io, connection.async_close()))); +} + +BOOST_AUTO_TEST_CASE(noto_detached_failed_commit_rolls_back) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("detached_failed_commit"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE parents(id INTEGER PRIMARY KEY)"))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE children(parent_id INTEGER NOT NULL, FOREIGN KEY(parent_id) REFERENCES parents(id) DEFERRABLE INITIALLY DEFERRED)"))); + + auto transaction = run(io, connection.async_begin_transaction(noto::transaction_mode::DEFERRED)); + BOOST_REQUIRE(transaction); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO children(parent_id) VALUES(42)"))); + + auto commit_future = boost::asio::co_spawn(io, noto::transaction{std::move(*transaction)}.async_commit(), boost::asio::use_future); + io.run(); + io.restart(); + auto committed = commit_future.get(); + BOOST_TEST(!static_cast(committed)); + BOOST_TEST(static_cast(committed.error().code()) == static_cast(noto::detail::error_constraint_violation)); + + auto count = run(io, connection.async_execute("SELECT COUNT(*) FROM children")); + BOOST_REQUIRE(count); + BOOST_TEST(int_value(count->rows.at(0).at(0)) == 0); + + auto next = run(io, connection.async_begin_transaction()); + BOOST_REQUIRE(next); + BOOST_TEST(static_cast(run(io, next->async_rollback()))); + BOOST_TEST(static_cast(run(io, connection.async_close()))); +} + +BOOST_AUTO_TEST_CASE(noto_abandoned_transaction_rolls_back_before_next_operation) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config(), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value TEXT NOT NULL)"))); + + { + auto transaction = run(io, connection.async_begin_transaction()); + BOOST_REQUIRE(transaction); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('abandoned')"))); + } + + auto count = run(io, connection.async_execute("SELECT COUNT(*) FROM items")); + BOOST_REQUIRE(count); + BOOST_TEST(int_value(count->rows.at(0).at(0)) == 0); + BOOST_TEST(static_cast(run(io, connection.async_close()))); +} + +BOOST_AUTO_TEST_CASE(noto_transaction_callback_exceptions_return_status_and_rollback) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config(), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value TEXT NOT NULL)"))); + + auto status = run(io, connection.async_with_transaction(noto::transaction_mode::IMMEDIATE, [](noto::connection& active) -> boost::asio::awaitable> { + auto inserted = co_await active.async_execute("INSERT INTO items(value) VALUES('temporary')"); + if (!inserted) { + co_return std::unexpected(inserted.error()); + } + throw std::runtime_error{"boom"}; + })); + BOOST_TEST(!static_cast(status)); + BOOST_TEST(static_cast(status.error().code()) == static_cast(noto::detail::error_internal)); + + auto count = run(io, connection.async_execute("SELECT COUNT(*) FROM items")); + BOOST_REQUIRE(count); + BOOST_TEST(int_value(count->rows.at(0).at(0)) == 0); +} + +BOOST_AUTO_TEST_CASE(noto_execute_options_limit_collected_results) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("limits"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value TEXT NOT NULL)"))); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('one'), ('two')"))); + + auto row_options = noto::execute_options{}; + row_options.max_rows = 1; + auto too_many_rows = run(io, connection.async_execute("SELECT value FROM items ORDER BY value", {}, row_options)); + BOOST_REQUIRE(!too_many_rows); + BOOST_TEST(static_cast(too_many_rows.error().code()) == static_cast(noto::detail::error_resource_exhausted)); + + auto byte_options = noto::execute_options{}; + byte_options.max_result_bytes = 4; + auto too_many_bytes = run(io, connection.async_execute("SELECT value FROM items WHERE value = 'one'", {}, byte_options)); + BOOST_REQUIRE(!too_many_bytes); + BOOST_TEST(static_cast(too_many_bytes.error().code()) == static_cast(noto::detail::error_resource_exhausted)); + + auto statement = run(io, connection.async_prepare("SELECT value FROM items ORDER BY value")); + BOOST_REQUIRE(statement); + auto statement_options = noto::execute_options{}; + statement_options.max_rows = 1; + auto statement_limit = run(io, statement->async_execute({}, statement_options)); + BOOST_REQUIRE(!statement_limit); + BOOST_TEST(static_cast(statement_limit.error().code()) == static_cast(noto::detail::error_resource_exhausted)); + auto after_limit = run(io, connection.async_execute("INSERT INTO items(value) VALUES('three')")); + BOOST_REQUIRE(after_limit); + BOOST_TEST(static_cast(run(io, statement->async_finalize()))); + BOOST_TEST(static_cast(run(io, connection.async_close()))); +} + +BOOST_AUTO_TEST_CASE(noto_migrations_apply_validate_and_rollback) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config(), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + + auto migrations = std::vector{ + { + .id = "001_create_table", + .description = "create items", + .checksum = "checksum-001", + .up = [](noto::connection& active) -> boost::asio::awaitable> { + co_return co_await execute_status(active, "CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"); + }, + .down = [](noto::connection& active) -> boost::asio::awaitable> { + co_return co_await execute_status(active, "DROP TABLE IF EXISTS items"); + }, + }, + }; + auto migrator = noto::migrator{connection}; + BOOST_TEST(static_cast(run(io, migrator.async_migrate(migrations)))); + BOOST_TEST(static_cast(run(io, migrator.async_migrate(migrations)))); + + auto changed = migrations; + changed.at(0).checksum = "changed"; + auto mismatch = run(io, migrator.async_migrate(changed)); + BOOST_TEST(!static_cast(mismatch)); + BOOST_TEST(static_cast(mismatch.error().code()) == static_cast(noto::detail::error_invalid_argument)); + + auto records = run(io, migrator.async_applied_migrations()); + BOOST_REQUIRE(records); + BOOST_TEST(records->size() == 1U); + BOOST_TEST(records->at(0).id == "001_create_table"); + + BOOST_TEST(static_cast(run(io, migrator.async_rollback(migrations)))); + auto missing = run(io, connection.async_execute("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'items'")); + BOOST_REQUIRE(missing); + BOOST_TEST(static_cast(missing->rows.empty())); +} + +BOOST_AUTO_TEST_CASE(noto_backup_export_integrity_and_pragmas_work) { + auto io = boost::asio::io_context{}; + auto source_path = temp_database_path("source"); + auto backup_path = temp_database_path("backup"); + auto failed_backup_path = temp_database_path("failed_backup"); + auto oversized_retry_path = temp_database_path("oversized_retry"); + auto export_path = temp_database_path("export_sql"); + + auto connection = noto::connection{ + io.get_executor(), + noto::file_connection_config(source_path), + }; + auto startup_config = connection.config(); + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_TEST(static_cast(run(io, connection.async_set_user_version(12)))); + auto version = run(io, connection.async_user_version()); + BOOST_REQUIRE(version); + BOOST_TEST(*version == 12); + BOOST_TEST(static_cast(run(io, connection.async_set_busy_timeout(std::chrono::milliseconds{37})))); + auto after_timeout_config = connection.config(); + BOOST_TEST(after_timeout_config.busy_timeout == startup_config.busy_timeout); + auto busy_timeout = run(io, connection.async_busy_timeout()); + BOOST_REQUIRE(busy_timeout); + BOOST_TEST(*busy_timeout == std::chrono::milliseconds{37}); + + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"))); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('one'), ('two')"))); + auto integrity = run(io, connection.async_integrity_check()); + BOOST_REQUIRE(integrity); + BOOST_REQUIRE(integrity->size() == 1U); + BOOST_TEST(integrity->at(0) == "ok"); + + BOOST_TEST(static_cast(run(io, connection.async_backup_to_file(backup_path)))); + auto exported = run(io, connection.async_export_sql()); + BOOST_REQUIRE(exported); + BOOST_TEST(exported->find("CREATE TABLE items") != std::string::npos); + auto limited_options = noto::export_options{}; + limited_options.max_output_bytes = 16; + auto limited_export = run(io, connection.async_export_sql(limited_options)); + BOOST_REQUIRE(!limited_export); + BOOST_TEST(static_cast(limited_export.error().code()) == static_cast(noto::detail::error_resource_exhausted)); + + auto* preserved = static_cast(nullptr); + BOOST_REQUIRE(sqlite3_open_v2(failed_backup_path.c_str(), &preserved, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nullptr) == SQLITE_OK); + BOOST_REQUIRE(sqlite_exec_ok(preserved, "CREATE TABLE preserved(value INTEGER); INSERT INTO preserved(value) VALUES(42)")); + sqlite3_close(preserved); + auto bad_backup_options = noto::backup_options{}; + bad_backup_options.source_name = "missing"; + auto failed_backup = run(io, connection.async_backup_to_file(failed_backup_path, bad_backup_options)); + BOOST_TEST(!static_cast(failed_backup)); + preserved = nullptr; + BOOST_REQUIRE(sqlite3_open_v2(failed_backup_path.c_str(), &preserved, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nullptr) == SQLITE_OK); + BOOST_TEST(sqlite_scalar_int64(preserved, "SELECT value FROM preserved") == 42); + sqlite3_close(preserved); + + auto oversized_retry = noto::backup_options{}; + oversized_retry.retry_delay = std::chrono::milliseconds{static_cast(std::numeric_limits::max()) + 1}; + auto failed_delay = run(io, connection.async_backup_to_file(oversized_retry_path, oversized_retry)); + BOOST_TEST(static_cast(failed_delay.error().code()) == static_cast(noto::detail::error_invalid_argument)); + + oversized_retry = noto::backup_options{}; + oversized_retry.retry_timeout = std::chrono::milliseconds{static_cast(std::numeric_limits::max()) + 1}; + auto failed_timeout = run(io, connection.async_backup_to_file(oversized_retry_path, oversized_retry)); + BOOST_TEST(static_cast(failed_timeout.error().code()) == static_cast(noto::detail::error_invalid_argument)); + + write_file(export_path, "preserved export"); + auto limited_file_options = noto::export_options{}; + limited_file_options.max_output_bytes = 16; + auto limited_file_export = run(io, connection.async_export_sql_to_file(export_path, limited_file_options)); + BOOST_REQUIRE(!limited_file_export); + BOOST_TEST(static_cast(limited_file_export.error().code()) == static_cast(noto::detail::error_resource_exhausted)); + BOOST_TEST(read_file(export_path) == "preserved export"); + + BOOST_TEST(static_cast(run(io, connection.async_export_sql_to_file(export_path)))); + BOOST_TEST(static_cast(std::filesystem::exists(export_path))); + BOOST_TEST(static_cast(run(io, connection.async_close()))); + + auto restored = noto::connection{ + io.get_executor(), + noto::file_connection_config(backup_path), + }; + BOOST_TEST(static_cast(run(io, restored.async_connect()))); + auto count = run(io, restored.async_execute("SELECT COUNT(*) FROM items")); + BOOST_REQUIRE(count); + BOOST_TEST(int_value(count->rows.at(0).at(0)) == 2); + BOOST_TEST(static_cast(run(io, restored.async_close()))); + + std::filesystem::remove(source_path); + std::filesystem::remove(backup_path); + std::filesystem::remove(failed_backup_path); + std::filesystem::remove(oversized_retry_path); + std::filesystem::remove(export_path); +} + +BOOST_AUTO_TEST_CASE(noto_backup_and_export_reject_live_database_destinations) { + auto io = boost::asio::io_context{}; + auto source_path = temp_database_path("protected_destination_source"); + auto link_path = temp_database_path("protected_destination_link"); + auto connection = noto::connection{ + io.get_executor(), + noto::file_connection_config(source_path), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value TEXT NOT NULL)"))); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('safe')"))); + + auto self_backup = run(io, connection.async_backup_to_file(source_path)); + BOOST_TEST(static_cast(self_backup.error().code()) == static_cast(noto::detail::error_invalid_argument)); + auto self_export = run(io, connection.async_export_sql_to_file(source_path)); + BOOST_TEST(static_cast(self_export.error().code()) == static_cast(noto::detail::error_invalid_argument)); + + for (auto suffix : std::array{"-wal", "-shm", "-journal"}) { + auto backup = run(io, connection.async_backup_to_file(source_path + std::string{suffix})); + BOOST_TEST(static_cast(backup.error().code()) == static_cast(noto::detail::error_invalid_argument)); + auto exported = run(io, connection.async_export_sql_to_file(source_path + std::string{suffix})); + BOOST_TEST(static_cast(exported.error().code()) == static_cast(noto::detail::error_invalid_argument)); + } + + auto error = std::error_code{}; + std::filesystem::create_symlink(source_path, link_path, error); + if (!error) { + auto link_backup = run(io, connection.async_backup_to_file(link_path)); + BOOST_TEST(static_cast(link_backup.error().code()) == static_cast(noto::detail::error_invalid_argument)); + auto link_export = run(io, connection.async_export_sql_to_file(link_path)); + BOOST_TEST(static_cast(link_export.error().code()) == static_cast(noto::detail::error_invalid_argument)); + } + + auto rows = run(io, connection.async_execute("SELECT value FROM items")); + BOOST_REQUIRE(rows); + BOOST_REQUIRE(rows->rows.size() == 1U); + BOOST_TEST(text_value(rows->rows.at(0).at(0)) == "safe"); + BOOST_TEST(static_cast(run(io, connection.async_close()))); + std::filesystem::remove(link_path); + remove_database_files(source_path); +} + +BOOST_AUTO_TEST_CASE(noto_restore_to_file_is_atomic_and_reopens_connection) { + auto io = boost::asio::io_context{}; + auto source_path = temp_database_path("restore_source"); + auto target_path = temp_database_path("restore_target"); + + BOOST_REQUIRE(create_sqlite_database(source_path, "CREATE TABLE items(value TEXT NOT NULL); INSERT INTO items(value) VALUES('new')")); + + auto connection = noto::connection{ + io.get_executor(), + noto::file_connection_config(target_path), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + auto journal = run(io, connection.async_journal_mode("WAL")); + BOOST_REQUIRE(journal); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value TEXT NOT NULL)"))); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('old')"))); + BOOST_TEST(static_cast(run(io, connection.async_set_busy_timeout(std::chrono::milliseconds{37})))); + write_file(target_path + "-journal", "stale rollback journal"); + BOOST_TEST(static_cast(std::filesystem::exists(target_path + "-journal"))); + + BOOST_TEST(static_cast(run(io, connection.async_restore_from_file(source_path)))); + BOOST_TEST(static_cast(connection.state()) == static_cast(noto::connection_state::CONNECTED)); + auto timeout = run(io, connection.async_busy_timeout()); + BOOST_REQUIRE(timeout); + BOOST_TEST(*timeout == std::chrono::milliseconds{37}); + + auto rows = run(io, connection.async_execute("SELECT value FROM items")); + BOOST_REQUIRE(rows); + BOOST_REQUIRE(rows->rows.size() == 1U); + BOOST_TEST(text_value(rows->rows.at(0).at(0)) == "new"); + BOOST_TEST(static_cast(run(io, connection.async_close()))); + BOOST_TEST(!static_cast(std::filesystem::exists(target_path + "-wal"))); + BOOST_TEST(!static_cast(std::filesystem::exists(target_path + "-shm"))); + BOOST_TEST(!static_cast(std::filesystem::exists(target_path + "-journal"))); + + auto* restored = static_cast(nullptr); + BOOST_REQUIRE(sqlite3_open_v2(target_path.c_str(), &restored, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nullptr) == SQLITE_OK); + BOOST_TEST(sqlite_scalar_int64(restored, "SELECT COUNT(*) FROM items WHERE value = 'new'") == 1); + sqlite3_close(restored); + + remove_database_files(source_path); + remove_database_files(target_path); +} + +BOOST_AUTO_TEST_CASE(noto_failed_restore_leaves_existing_database_intact) { + auto io = boost::asio::io_context{}; + auto source_path = temp_database_path("restore_failure_source"); + auto target_path = temp_database_path("restore_failure_target"); + + BOOST_REQUIRE(create_sqlite_database(source_path, "CREATE TABLE items(value TEXT NOT NULL); INSERT INTO items(value) VALUES('new')")); + BOOST_REQUIRE(create_sqlite_database(target_path, "CREATE TABLE items(value TEXT NOT NULL); INSERT INTO items(value) VALUES('old')")); + + auto connection = noto::connection{ + io.get_executor(), + noto::file_connection_config(target_path), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + auto options = noto::backup_options{}; + options.source_name = "missing"; + auto restored = run(io, connection.async_restore_from_file(source_path, options)); + BOOST_TEST(!static_cast(restored)); + + auto rows = run(io, connection.async_execute("SELECT value FROM items")); + BOOST_REQUIRE(rows); + BOOST_REQUIRE(rows->rows.size() == 1U); + BOOST_TEST(text_value(rows->rows.at(0).at(0)) == "old"); + BOOST_TEST(static_cast(run(io, connection.async_close()))); + + remove_database_files(source_path); + remove_database_files(target_path); +} + +BOOST_AUTO_TEST_CASE(noto_restore_rejects_unsupported_targets_and_active_state) { + auto io = boost::asio::io_context{}; + auto source_path = temp_database_path("restore_reject_source"); + auto target_path = temp_database_path("restore_reject_target"); + + BOOST_REQUIRE(create_sqlite_database(source_path, "CREATE TABLE items(value TEXT NOT NULL); INSERT INTO items(value) VALUES('new')")); + BOOST_REQUIRE(create_sqlite_database(target_path, "CREATE TABLE items(value TEXT NOT NULL); INSERT INTO items(value) VALUES('old')")); + + auto memory = noto::connection{ + io.get_executor(), + noto::memory_connection_config("restore_reject_memory"), + }; + BOOST_TEST(static_cast(run(io, memory.async_connect()))); + auto memory_restore = run(io, memory.async_restore_from_file(source_path)); + BOOST_TEST(static_cast(memory_restore.error().code()) == static_cast(noto::detail::error_invalid_argument)); + BOOST_TEST(static_cast(run(io, memory.async_close()))); + + auto connection = noto::connection{ + io.get_executor(), + noto::file_connection_config(target_path), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + + auto options = noto::backup_options{}; + options.destination_name = "attached"; + auto non_main = run(io, connection.async_restore_from_file(source_path, options)); + BOOST_TEST(static_cast(non_main.error().code()) == static_cast(noto::detail::error_invalid_argument)); + + auto transaction = run(io, connection.async_begin_transaction()); + BOOST_REQUIRE(transaction); + auto active_transaction = run(io, connection.async_restore_from_file(source_path)); + BOOST_TEST(static_cast(active_transaction.error().code()) == static_cast(noto::detail::error_unavailable)); + BOOST_TEST(static_cast(run(io, transaction->async_rollback()))); + + auto statement = run(io, connection.async_prepare("SELECT value FROM items")); + BOOST_REQUIRE(statement); + auto live_statement = run(io, connection.async_restore_from_file(source_path)); + BOOST_TEST(static_cast(live_statement.error().code()) == static_cast(noto::detail::error_unavailable)); + BOOST_TEST(static_cast(run(io, statement->async_finalize()))); + BOOST_TEST(static_cast(run(io, connection.async_close()))); + + remove_database_files(source_path); + remove_database_files(target_path); +} + +BOOST_AUTO_TEST_CASE(noto_backup_lock_timeout_and_statement_finalize_retry_work) { + auto io = boost::asio::io_context{}; + auto source_path = temp_database_path("locked_source"); + auto backup_path = temp_database_path("locked_backup"); + + auto connection = noto::connection{ + io.get_executor(), + noto::file_connection_config(source_path), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value TEXT NOT NULL)"))); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('one')"))); + auto statement = run(io, connection.async_prepare("SELECT value FROM items")); + BOOST_REQUIRE(statement); + + auto* blocker = static_cast(nullptr); + BOOST_REQUIRE(sqlite3_open_v2(source_path.c_str(), &blocker, SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nullptr) == SQLITE_OK); + BOOST_REQUIRE(sqlite_exec_ok(blocker, "BEGIN EXCLUSIVE")); + + auto options = noto::backup_options{}; + options.pages_per_step = 1; + options.retry_delay = std::chrono::milliseconds{1}; + options.retry_timeout = std::chrono::milliseconds{25}; + + auto backup_future = boost::asio::co_spawn(io, connection.async_backup_to_file(backup_path, options), boost::asio::use_future); + auto finalize_future = boost::asio::co_spawn(io, statement->async_finalize(), boost::asio::use_future); + io.run(); + io.restart(); + + auto backup = backup_future.get(); + auto finalized_while_busy = finalize_future.get(); + BOOST_TEST(static_cast(backup.error().code()) == static_cast(noto::detail::error_timeout)); + + BOOST_TEST(static_cast(sqlite_exec_ok(blocker, "ROLLBACK"))); + sqlite3_close(blocker); + if (!finalized_while_busy) { + BOOST_TEST(static_cast(finalized_while_busy.error().code()) == static_cast(noto::detail::error_unavailable)); + BOOST_TEST(static_cast(run(io, statement->async_finalize()))); + } + BOOST_TEST(static_cast(run(io, connection.async_close()))); + + std::filesystem::remove(source_path); + std::filesystem::remove(backup_path); +} + +BOOST_AUTO_TEST_CASE(noto_export_preserves_nul_text_and_defers_triggers_until_after_data) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("export_exact"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE audit(count INTEGER NOT NULL)"))); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO audit(count) VALUES(0)"))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(value TEXT NOT NULL)"))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN UPDATE audit SET count = count + 1; END"))); + + auto insert = run(io, connection.async_prepare("INSERT INTO items(value) VALUES(?1)")); + BOOST_REQUIRE(insert); + auto embedded = std::string{ + "a\0b", + 3, + }; + BOOST_REQUIRE(run(io, insert->async_execute({ + noto::sqlite_value{embedded}, + }))); + BOOST_TEST(static_cast(run(io, insert->async_finalize()))); + + auto exported = run(io, connection.async_export_sql()); + BOOST_REQUIRE(exported); + BOOST_TEST(exported->find("CAST(X'610062' AS TEXT)") != std::string::npos); + BOOST_TEST(exported->find("INSERT INTO \"items\"") < exported->find("CREATE TRIGGER")); + + auto* restored = static_cast(nullptr); + BOOST_REQUIRE(sqlite3_open(":memory:", &restored) == SQLITE_OK); + BOOST_REQUIRE(sqlite_exec_ok(restored, *exported)); + + auto* statement = static_cast(nullptr); + BOOST_REQUIRE(sqlite3_prepare_v2(restored, "SELECT value FROM items", -1, &statement, nullptr) == SQLITE_OK); + BOOST_REQUIRE(sqlite3_step(statement) == SQLITE_ROW); + auto const* text = reinterpret_cast(sqlite3_column_text(statement, 0)); + auto bytes = sqlite3_column_bytes(statement, 0); + auto restored_text = std::string{ + text, + static_cast(bytes), + }; + sqlite3_finalize(statement); + BOOST_TEST(restored_text == embedded); + + statement = nullptr; + BOOST_REQUIRE(sqlite3_prepare_v2(restored, "SELECT count FROM audit", -1, &statement, nullptr) == SQLITE_OK); + BOOST_REQUIRE(sqlite3_step(statement) == SQLITE_ROW); + BOOST_TEST(sqlite3_column_int64(statement, 0) == 1); + sqlite3_finalize(statement); + sqlite3_close(restored); +} + +BOOST_AUTO_TEST_CASE(noto_export_preserves_autoincrement_sequence_and_generated_columns) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("export_sequence_generated"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + BOOST_REQUIRE(run(io, connection.async_execute("CREATE TABLE items(id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL, value_size INTEGER GENERATED ALWAYS AS (length(value)) STORED)"))); + BOOST_REQUIRE(run(io, connection.async_execute("INSERT INTO items(value) VALUES('one'), ('two')"))); + BOOST_REQUIRE(run(io, connection.async_execute("DELETE FROM items WHERE value = 'two'"))); + + auto exported = run(io, connection.async_export_sql()); + BOOST_REQUIRE(exported); + BOOST_TEST(exported->find("sqlite_sequence") != std::string::npos); + BOOST_TEST(exported->find("value_size") != std::string::npos); + BOOST_TEST(exported->find("INSERT INTO \"items\"(\"id\", \"value\")") != std::string::npos); + + auto* restored = static_cast(nullptr); + BOOST_REQUIRE(sqlite3_open(":memory:", &restored) == SQLITE_OK); + BOOST_REQUIRE(sqlite_exec_ok(restored, *exported)); + BOOST_REQUIRE(sqlite_exec_ok(restored, "INSERT INTO items(value) VALUES('three')")); + BOOST_TEST(sqlite_scalar_int64(restored, "SELECT id FROM items WHERE value = 'three'") == 3); + BOOST_TEST(sqlite_scalar_int64(restored, "SELECT value_size FROM items WHERE value = 'three'") == 5); + sqlite3_close(restored); +} + +BOOST_AUTO_TEST_CASE(noto_export_rejects_virtual_tables) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("export_virtual"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + auto created = run(io, connection.async_execute("CREATE VIRTUAL TABLE docs USING fts5(content)")); + if (!created) { + return; + } + + auto exported = run(io, connection.async_export_sql()); + BOOST_REQUIRE(!exported); + BOOST_TEST(static_cast(exported.error().code()) == static_cast(noto::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(noto_file_helpers_reject_null_byte_paths) { + auto io = boost::asio::io_context{}; + auto connection = noto::connection{ + io.get_executor(), + noto::memory_connection_config("path_validation"), + }; + BOOST_TEST(static_cast(run(io, connection.async_connect()))); + + auto bad_path = std::string{ + "bad\0path", + 8, + }; + auto backup = run(io, connection.async_backup_to_file(bad_path)); + BOOST_TEST(static_cast(backup.error().code()) == static_cast(noto::detail::error_invalid_argument)); + auto exported = run(io, connection.async_export_sql_to_file(bad_path)); + BOOST_TEST(static_cast(exported.error().code()) == static_cast(noto::detail::error_invalid_argument)); + + BOOST_TEST(static_cast(run(io, connection.async_close()))); +} diff --git a/tests/test.hpp b/tests/test.hpp new file mode 100644 index 0000000..02c7626 --- /dev/null +++ b/tests/test.hpp @@ -0,0 +1,4 @@ +#pragma once + +#define BOOST_TEST_MODULE noto +#include