This commit is contained in:
@@ -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
|
||||
Executable
+20
@@ -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
|
||||
Executable
+72
@@ -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
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
|
||||
)
|
||||
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
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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=<user> \
|
||||
CONAN_TOKEN=<token> \
|
||||
NOTO_CONAN_UPLOAD=1 \
|
||||
MODULUS_ALLOW_PACKAGE_REPLACE=1 \
|
||||
python3 modulus.py package --all --replace
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Noto is released under the MIT License.
|
||||
@@ -0,0 +1,6 @@
|
||||
@PACKAGE_INIT@
|
||||
|
||||
include(CMakeFindDependencyMacro)
|
||||
find_dependency(modulus CONFIG REQUIRED)
|
||||
|
||||
include("${CMAKE_CURRENT_LIST_DIR}/noto-targets.cmake")
|
||||
+107
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <noto/config.hpp>
|
||||
#include <noto/connection.hpp>
|
||||
#include <noto/errors.hpp>
|
||||
#include <noto/migration.hpp>
|
||||
#include <noto/schema.hpp>
|
||||
#include <noto/transaction.hpp>
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/json/value.hpp>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
namespace noto {
|
||||
|
||||
enum class open_mode {
|
||||
READ_ONLY,
|
||||
READ_WRITE,
|
||||
READ_WRITE_CREATE,
|
||||
MEMORY,
|
||||
};
|
||||
|
||||
struct pragma_setting {
|
||||
std::string name{};
|
||||
std::variant<std::bool_t, std::int64_t, std::string> 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<noto::pragma_setting> pragmas{};
|
||||
};
|
||||
|
||||
auto validate(noto::connection_config const& config) -> std::expected<void, modulus::error>;
|
||||
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<noto::connection_config, modulus::error>;
|
||||
auto parse_database_configs(boost::json::value const& value) -> std::expected<std::vector<noto::connection_config>, modulus::error>;
|
||||
auto load_database_configs(std::string const& path) -> std::expected<std::vector<noto::connection_config>, modulus::error>;
|
||||
auto to_json(std::variant<std::bool_t, std::int64_t, std::string> const& value) -> boost::json::value;
|
||||
auto to_json(noto::connection_config const& config) -> boost::json::value;
|
||||
auto to_json(std::vector<noto::connection_config> const& configs) -> boost::json::value;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <exception>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <noto/config.hpp>
|
||||
#include <noto/detail/errors.hpp>
|
||||
#include <noto/schema.hpp>
|
||||
#include <noto/transaction.hpp>
|
||||
|
||||
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<std::byte> value);
|
||||
auto is_null() const -> std::bool_t;
|
||||
std::variant<std::monostate, std::int64_t, std::float64_t, std::string, std::vector<std::byte>> value{};
|
||||
};
|
||||
|
||||
struct query_result {
|
||||
std::vector<std::string> columns{};
|
||||
std::vector<std::vector<noto::sqlite_value>> 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<std::chrono::milliseconds> retry_timeout{};
|
||||
};
|
||||
|
||||
struct execute_options {
|
||||
std::optional<std::size_t> max_rows{100000};
|
||||
std::optional<std::size_t> max_result_bytes{64ULL * 1024ULL * 1024ULL};
|
||||
};
|
||||
|
||||
struct export_options {
|
||||
std::optional<std::size_t> 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<noto::sqlite_value> params = {}) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_step() -> boost::asio::awaitable<std::expected<std::optional<std::vector<noto::sqlite_value>>, modulus::error>>;
|
||||
auto async_execute(std::vector<noto::sqlite_value> params = {}) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>>;
|
||||
auto async_execute(std::vector<noto::sqlite_value> params, noto::execute_options options) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>>;
|
||||
auto async_finalize() -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
private:
|
||||
friend class noto::connection;
|
||||
friend struct noto::detail::connection_core;
|
||||
explicit statement(std::shared_ptr<noto::detail::statement_core> core);
|
||||
std::shared_ptr<noto::detail::statement_core> 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<std::expected<void, modulus::error>>;
|
||||
auto async_close() -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_execute(std::string sql, std::vector<noto::sqlite_value> params = {}) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>>;
|
||||
auto async_execute(std::string sql, std::vector<noto::sqlite_value> params, noto::execute_options options) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>>;
|
||||
auto async_prepare(std::string sql) -> boost::asio::awaitable<std::expected<noto::statement, modulus::error>>;
|
||||
auto async_begin_transaction(noto::transaction_mode mode = noto::transaction_mode::IMMEDIATE) -> boost::asio::awaitable<std::expected<noto::transaction, modulus::error>>;
|
||||
template <typename CALLBACK>
|
||||
auto async_with_transaction(noto::transaction_mode mode, CALLBACK callback) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_create_table(noto::table_definition definition) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_drop_table(noto::table_name table, std::bool_t if_exists = true) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_create_index(noto::index_definition definition) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_drop_index(noto::index_definition definition) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_backup_to_file(std::string path) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_backup_to_file(std::string path, noto::backup_options options) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_restore_from_file(std::string path) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_restore_from_file(std::string path, noto::backup_options options) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_export_sql() -> boost::asio::awaitable<std::expected<std::string, modulus::error>>;
|
||||
auto async_export_sql(noto::export_options options) -> boost::asio::awaitable<std::expected<std::string, modulus::error>>;
|
||||
auto async_export_sql_to_file(std::string path) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_export_sql_to_file(std::string path, noto::export_options options) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_integrity_check() -> boost::asio::awaitable<std::expected<std::vector<std::string>, modulus::error>>;
|
||||
auto async_quick_check() -> boost::asio::awaitable<std::expected<std::vector<std::string>, modulus::error>>;
|
||||
auto async_foreign_key_check() -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>>;
|
||||
auto async_get_pragma(std::string name) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>>;
|
||||
auto async_set_pragma(std::string name, std::variant<std::bool_t, std::int64_t, std::string> value) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_foreign_keys() -> boost::asio::awaitable<std::expected<std::bool_t, modulus::error>>;
|
||||
auto async_set_foreign_keys(std::bool_t enabled) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_busy_timeout() -> boost::asio::awaitable<std::expected<std::chrono::milliseconds, modulus::error>>;
|
||||
auto async_set_busy_timeout(std::chrono::milliseconds timeout) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_user_version() -> boost::asio::awaitable<std::expected<std::int64_t, modulus::error>>;
|
||||
auto async_set_user_version(std::int64_t version) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_application_id() -> boost::asio::awaitable<std::expected<std::int64_t, modulus::error>>;
|
||||
auto async_set_application_id(std::int64_t id) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_journal_mode(std::string mode) -> boost::asio::awaitable<std::expected<std::string, modulus::error>>;
|
||||
auto async_wal_checkpoint(std::string mode = "PASSIVE") -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>>;
|
||||
private:
|
||||
friend class noto::transaction;
|
||||
explicit connection(std::shared_ptr<noto::detail::connection_core> core);
|
||||
auto core() const -> std::shared_ptr<noto::detail::connection_core>;
|
||||
std::shared_ptr<noto::detail::connection_core> core_{};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
template <typename CALLBACK>
|
||||
auto noto::connection::async_with_transaction(noto::transaction_mode mode, CALLBACK callback) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
return [](noto::connection connection, noto::transaction_mode mode, CALLBACK callback) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<void, modulus::error>{};
|
||||
auto exception_status = std::optional<modulus::error>{};
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
#include <boost/asio/strand.hpp>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <noto/connection.hpp>
|
||||
#include <noto/config.hpp>
|
||||
#include <noto/transaction.hpp>
|
||||
|
||||
namespace noto::detail {
|
||||
|
||||
struct transaction_result {
|
||||
std::expected<void, modulus::error> status{};
|
||||
std::bool_t active{};
|
||||
};
|
||||
|
||||
struct connection_core : std::enable_shared_from_this<noto::detail::connection_core> {
|
||||
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<std::expected<void, modulus::error>>;
|
||||
auto async_close() -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_execute(std::string sql, std::vector<noto::sqlite_value> params, noto::execute_options options) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>>;
|
||||
auto async_prepare(std::string sql) -> boost::asio::awaitable<std::expected<noto::statement, modulus::error>>;
|
||||
auto async_begin_transaction(noto::transaction_mode mode) -> boost::asio::awaitable<std::expected<noto::transaction, modulus::error>>;
|
||||
auto async_commit_transaction() -> boost::asio::awaitable<noto::detail::transaction_result>;
|
||||
auto async_rollback_transaction() -> boost::asio::awaitable<noto::detail::transaction_result>;
|
||||
auto async_backup_to_file(std::string path, noto::backup_options options) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_restore_from_file(std::string path, noto::backup_options options) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_export_sql(noto::export_options options) -> boost::asio::awaitable<std::expected<std::string, modulus::error>>;
|
||||
auto async_export_sql_to_file(std::string path, noto::export_options options) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_set_busy_timeout(std::chrono::milliseconds timeout) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
boost::asio::any_io_executor executor{};
|
||||
boost::asio::strand<boost::asio::any_io_executor> strand;
|
||||
noto::connection_config config{};
|
||||
std::chrono::milliseconds busy_timeout{};
|
||||
sqlite3* database{};
|
||||
std::shared_ptr<std::atomic<noto::connection_state>> state{std::make_shared<std::atomic<noto::connection_state>>(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<noto::detail::connection_core> connection, sqlite3_stmt* statement);
|
||||
~statement_core();
|
||||
auto async_reset(std::vector<noto::sqlite_value> params) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_step() -> boost::asio::awaitable<std::expected<std::optional<std::vector<noto::sqlite_value>>, modulus::error>>;
|
||||
auto async_execute(std::vector<noto::sqlite_value> params, noto::execute_options options) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>>;
|
||||
auto async_finalize() -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
std::shared_ptr<noto::detail::connection_core> connection{};
|
||||
sqlite3_stmt* statement{};
|
||||
std::atomic_bool finalized{};
|
||||
std::bool_t counted{};
|
||||
};
|
||||
|
||||
struct transaction_state {
|
||||
transaction_state(std::shared_ptr<noto::detail::connection_core> core, std::function<void()> on_finish = {});
|
||||
auto finish() noexcept -> void;
|
||||
std::shared_ptr<noto::detail::connection_core> core{};
|
||||
std::function<void()> on_finish{};
|
||||
std::atomic_bool active{false};
|
||||
std::atomic_bool detached{false};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
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<void, modulus::error>;
|
||||
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<void, modulus::error>;
|
||||
auto fail_void(std::expected<void, modulus::error> const& result) -> std::expected<void, modulus::error>;
|
||||
|
||||
template <typename VALUE>
|
||||
auto fail(modulus::error error) -> std::expected<VALUE, modulus::error>;
|
||||
|
||||
template <typename VALUE>
|
||||
auto fail(std::expected<void, modulus::error> const& result) -> std::expected<VALUE, modulus::error>;
|
||||
|
||||
template <typename VALUE>
|
||||
auto fail(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected<VALUE, modulus::error>;
|
||||
|
||||
}
|
||||
|
||||
template <typename VALUE>
|
||||
auto noto::detail::fail(modulus::error error) -> std::expected<VALUE, modulus::error> {
|
||||
return modulus::fail<VALUE>(std::move(error));
|
||||
}
|
||||
|
||||
template <typename VALUE>
|
||||
auto noto::detail::fail(std::expected<void, modulus::error> const& result) -> std::expected<VALUE, modulus::error> {
|
||||
assert(!result);
|
||||
return modulus::fail<VALUE>(result.error());
|
||||
}
|
||||
|
||||
template <typename VALUE>
|
||||
auto noto::detail::fail(std::int32_t code, std::string message, modulus::source_location location) -> std::expected<VALUE, modulus::error> {
|
||||
return noto::detail::fail<VALUE>(noto::detail::make_error(code, std::move(message), location));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
namespace noto::detail {
|
||||
|
||||
auto quote_sql_literal(std::string_view value, std::string_view label, std::bool_t allow_empty = true) -> std::expected<std::string, modulus::error>;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <expected>
|
||||
#include <string_view>
|
||||
|
||||
#include <modulus.hpp>
|
||||
#include <cstdint>
|
||||
|
||||
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<void, modulus::error>;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <noto/connection.hpp>
|
||||
#include <noto/schema.hpp>
|
||||
|
||||
namespace noto {
|
||||
|
||||
struct migration {
|
||||
std::string id{};
|
||||
std::string description{};
|
||||
std::string checksum{};
|
||||
std::function<boost::asio::awaitable<std::expected<void, modulus::error>>(noto::connection&)> up{};
|
||||
std::function<boost::asio::awaitable<std::expected<void, modulus::error>>(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<std::expected<std::vector<noto::migration_record>, modulus::error>>;
|
||||
auto async_migrate(std::vector<noto::migration> migrations) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_rollback(std::vector<noto::migration> migrations, std::string target_id = {}) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
private:
|
||||
noto::connection* connection_{};
|
||||
noto::migration_options options_{};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
namespace noto {
|
||||
|
||||
class trusted_sql_fragment {
|
||||
public:
|
||||
trusted_sql_fragment() = default;
|
||||
static auto unsafe(std::string sql) -> std::expected<noto::trusted_sql_fragment, modulus::error>;
|
||||
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<noto::column_type, modulus::error>;
|
||||
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<noto::column_type, modulus::error>;
|
||||
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<noto::default_value, modulus::error>;
|
||||
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<noto::default_value, modulus::error>;
|
||||
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<noto::default_value> default_value{};
|
||||
std::optional<noto::generated_column_definition> generated{};
|
||||
};
|
||||
|
||||
struct unique_constraint_definition {
|
||||
std::string name{};
|
||||
std::vector<std::string> 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<std::string> columns{};
|
||||
noto::table_name referenced_table{};
|
||||
std::vector<std::string> referenced_columns{};
|
||||
std::optional<noto::reference_action> on_delete{};
|
||||
std::optional<noto::reference_action> 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<noto::column_definition> columns{};
|
||||
std::vector<std::string> primary_key{};
|
||||
std::vector<noto::unique_constraint_definition> unique_constraints{};
|
||||
std::vector<noto::check_constraint_definition> check_constraints{};
|
||||
std::vector<noto::foreign_key_definition> 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<std::string> columns{};
|
||||
std::bool_t unique{false};
|
||||
};
|
||||
|
||||
auto quote_identifier(std::string_view identifier) -> std::expected<std::string, modulus::error>;
|
||||
auto create_table_sql(noto::table_definition const& definition) -> std::expected<std::string, modulus::error>;
|
||||
auto drop_table_sql(noto::table_name const& table, std::bool_t if_exists = true) -> std::expected<std::string, modulus::error>;
|
||||
auto create_index_sql(noto::index_definition const& definition) -> std::expected<std::string, modulus::error>;
|
||||
auto drop_index_sql(noto::index_definition const& definition) -> std::expected<std::string, modulus::error>;
|
||||
auto table_exists_sql() -> std::string;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
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<std::expected<void, modulus::error>>;
|
||||
auto async_rollback() -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
private:
|
||||
friend class noto::connection;
|
||||
friend struct noto::detail::connection_core;
|
||||
explicit transaction(std::shared_ptr<noto::detail::transaction_state> state);
|
||||
explicit transaction(std::shared_ptr<noto::detail::connection_core> core, std::function<void()> on_finish = {});
|
||||
std::shared_ptr<noto::detail::transaction_state> state_{};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"package": {
|
||||
"name": "noto"
|
||||
},
|
||||
"tooling": {
|
||||
"remote": {
|
||||
"name": "khodo",
|
||||
"url": "https://git.thyme-labs.com/api/packages/khodo/conan"
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+367
@@ -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",
|
||||
"<hidden>",
|
||||
],
|
||||
)
|
||||
|
||||
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:]))
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
#include <noto/config.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/json/array.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
#include <boost/json/value.hpp>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <noto/detail/errors.hpp>
|
||||
|
||||
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<void, modulus::error> {
|
||||
return noto::detail::fail_void(noto::detail::error_invalid_argument, std::move(message));
|
||||
}
|
||||
|
||||
template <typename VALUE>
|
||||
auto invalid_result(std::string message) -> std::expected<VALUE, modulus::error> {
|
||||
return noto::detail::fail<VALUE>(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::uint64_t>(std::numeric_limits<std::int32_t>::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<std::uchar_t>(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<std::string, modulus::error> {
|
||||
auto value = modulus::json_config::required_string_field(object, name, "database", json_options);
|
||||
if (!value) {
|
||||
return noto::detail::fail<std::string>(value.error());
|
||||
}
|
||||
if (contains_null(*value)) {
|
||||
return invalid_result<std::string>(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<std::optional<std::string>, modulus::error> {
|
||||
auto found = object.find(name);
|
||||
if (found == object.end()) {
|
||||
return std::optional<std::string>{};
|
||||
}
|
||||
auto parsed = modulus::json_config::optional_string_field(object, name, {}, json_options);
|
||||
if (!parsed) {
|
||||
return noto::detail::fail<std::optional<std::string>>(parsed.error());
|
||||
}
|
||||
auto value = std::move(*parsed);
|
||||
if (value.empty()) {
|
||||
return invalid_result<std::optional<std::string>>(std::string{"database "} + std::string{name} + " must not be empty");
|
||||
}
|
||||
if (contains_null(value)) {
|
||||
return invalid_result<std::optional<std::string>>(std::string{"database "} + std::string{name} + " must not contain null bytes");
|
||||
}
|
||||
return std::optional<std::string>{std::move(value)};
|
||||
}
|
||||
|
||||
auto parse_pragma_value(boost::json::value const& value, std::string_view name) -> std::expected<std::variant<std::bool_t, std::int64_t, std::string>, modulus::error> {
|
||||
if (value.is_bool()) {
|
||||
return std::variant<std::bool_t, std::int64_t, std::string>{value.as_bool()};
|
||||
}
|
||||
if (value.is_string()) {
|
||||
return std::variant<std::bool_t, std::int64_t, std::string>{
|
||||
std::string{value.as_string()},
|
||||
};
|
||||
}
|
||||
if (value.is_int64()) {
|
||||
return std::variant<std::bool_t, std::int64_t, std::string>{value.as_int64()};
|
||||
}
|
||||
if (value.is_uint64()) {
|
||||
if (value.as_uint64() > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())) {
|
||||
return invalid_result<std::variant<std::bool_t, std::int64_t, std::string>>(std::string{name} + " pragma value is out of range");
|
||||
}
|
||||
return std::variant<std::bool_t, std::int64_t, std::string>{static_cast<std::int64_t>(value.as_uint64())};
|
||||
}
|
||||
return invalid_result<std::variant<std::bool_t, std::int64_t, std::string>>(std::string{name} + " pragma value must be a string, integer, or boolean");
|
||||
}
|
||||
|
||||
auto parse_pragmas(boost::json::object const& object) -> std::expected<std::vector<noto::pragma_setting>, modulus::error> {
|
||||
auto found = object.find("pragmas");
|
||||
if (found == object.end()) {
|
||||
return std::vector<noto::pragma_setting>{};
|
||||
}
|
||||
auto pragma_object = modulus::json_config::require_object(found->value(), "database pragmas", json_options);
|
||||
if (!pragma_object) {
|
||||
return noto::detail::fail<std::vector<noto::pragma_setting>>(pragma_object.error());
|
||||
}
|
||||
auto pragmas = std::vector<noto::pragma_setting>{};
|
||||
for (auto const& item : **pragma_object) {
|
||||
auto name = std::string{item.key()};
|
||||
if (!valid_pragma_name(name)) {
|
||||
return invalid_result<std::vector<noto::pragma_setting>>("database pragma names must contain only letters, digits, or underscores");
|
||||
}
|
||||
auto value = parse_pragma_value(item.value(), name);
|
||||
if (!value) {
|
||||
return noto::detail::fail<std::vector<noto::pragma_setting>>(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<void, modulus::error> {
|
||||
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<std::uint64_t>(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<noto::connection_config, modulus::error> {
|
||||
auto object = modulus::json_config::require_object(value, "database", json_options);
|
||||
if (!object) {
|
||||
return noto::detail::fail<noto::connection_config>(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<noto::connection_config>(unknown.error());
|
||||
}
|
||||
auto name = required_string_field(**object, "name");
|
||||
if (!name) {
|
||||
return noto::detail::fail<noto::connection_config>(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<noto::connection_config>(mode.error());
|
||||
}
|
||||
auto filename = optional_string_field(**object, "filename");
|
||||
if (!filename) {
|
||||
return noto::detail::fail<noto::connection_config>(filename.error());
|
||||
}
|
||||
if (!*filename && *mode != noto::open_mode::MEMORY) {
|
||||
return noto::detail::fail<noto::connection_config>(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<noto::connection_config>(uri.error());
|
||||
}
|
||||
auto foreign_keys = modulus::json_config::optional_bool_field(**object, "foreign_keys", true, json_options);
|
||||
if (!foreign_keys) {
|
||||
return noto::detail::fail<noto::connection_config>(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<noto::connection_config>(timeout.error());
|
||||
}
|
||||
auto pragmas = parse_pragmas(**object);
|
||||
if (!pragmas) {
|
||||
return noto::detail::fail<noto::connection_config>(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<std::vector<noto::connection_config>, modulus::error> {
|
||||
auto object = modulus::json_config::require_object(value, "config root", json_options);
|
||||
if (!object) {
|
||||
return noto::detail::fail<std::vector<noto::connection_config>>(object.error());
|
||||
}
|
||||
auto unknown = modulus::json_config::reject_unknown_fields(**object, {"databases"}, "config root", json_options);
|
||||
if (!unknown) {
|
||||
return noto::detail::fail<std::vector<noto::connection_config>>(unknown.error());
|
||||
}
|
||||
auto found = (*object)->find("databases");
|
||||
if (found == (*object)->end() || !found->value().is_array()) {
|
||||
return invalid_result<std::vector<noto::connection_config>>("config databases array is required");
|
||||
}
|
||||
auto configs = std::vector<noto::connection_config>{};
|
||||
for (auto const& database_value : found->value().as_array()) {
|
||||
auto parsed = noto::parse_database_config(database_value);
|
||||
if (!parsed) {
|
||||
return noto::detail::fail<std::vector<noto::connection_config>>(parsed.error());
|
||||
}
|
||||
configs.push_back(std::move(*parsed));
|
||||
}
|
||||
return configs;
|
||||
}
|
||||
|
||||
auto noto::load_database_configs(std::string const& path) -> std::expected<std::vector<noto::connection_config>, modulus::error> {
|
||||
auto value = modulus::json_config::load_value(path, json_options);
|
||||
if (!value) {
|
||||
return noto::detail::fail<std::vector<noto::connection_config>>(value.error());
|
||||
}
|
||||
return noto::parse_database_configs(*value);
|
||||
}
|
||||
|
||||
auto noto::to_json(std::variant<std::bool_t, std::int64_t, std::string> 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<noto::connection_config> 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
#include <noto/connection.hpp>
|
||||
|
||||
#include <cassert>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
|
||||
#include <modulus.hpp>
|
||||
#include <modulus/log.hpp>
|
||||
|
||||
#include <noto/detail/core.hpp>
|
||||
#include <noto/detail/errors.hpp>
|
||||
#include <noto/detail/sql.hpp>
|
||||
#include <noto/schema.hpp>
|
||||
#include <noto/transaction.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
auto invalid_connection() -> std::expected<void, modulus::error> {
|
||||
return noto::detail::fail_void(noto::detail::error_invalid_argument, "database connection is not valid");
|
||||
}
|
||||
|
||||
auto invalid_statement() -> std::expected<void, modulus::error> {
|
||||
return noto::detail::fail_void(noto::detail::error_invalid_argument, "database statement is not valid");
|
||||
}
|
||||
|
||||
auto invalid(std::string message) -> std::expected<void, modulus::error> {
|
||||
return noto::detail::fail_void(noto::detail::error_invalid_argument, std::move(message));
|
||||
}
|
||||
|
||||
auto async_status(std::expected<void, modulus::error> status) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<std::uchar_t>(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<std::bool_t, std::int64_t, std::string> const& value) -> std::expected<std::string, modulus::error> {
|
||||
return std::visit([](auto const& current) -> std::expected<std::string, modulus::error> {
|
||||
if constexpr (std::is_same_v<std::decay_t<decltype(current)>, std::bool_t>) {
|
||||
return std::string{current ? "1" : "0"};
|
||||
} else if constexpr (std::is_same_v<std::decay_t<decltype(current)>, 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<std::int64_t, modulus::error> {
|
||||
if (result.rows.empty() || result.rows.front().empty()) {
|
||||
return noto::detail::fail<std::int64_t>(noto::detail::error_not_found, "query returned no rows");
|
||||
}
|
||||
auto const& value = result.rows.front().front().value;
|
||||
if (auto integer = std::get_if<std::int64_t>(&value)) {
|
||||
return *integer;
|
||||
}
|
||||
return noto::detail::fail<std::int64_t>(noto::detail::error_invalid_argument, "query did not return an integer");
|
||||
}
|
||||
|
||||
auto first_string(noto::query_result const& result) -> std::expected<std::string, modulus::error> {
|
||||
if (result.rows.empty() || result.rows.front().empty()) {
|
||||
return noto::detail::fail<std::string>(noto::detail::error_not_found, "query returned no rows");
|
||||
}
|
||||
auto const& value = result.rows.front().front().value;
|
||||
if (auto text = std::get_if<std::string>(&value)) {
|
||||
return *text;
|
||||
}
|
||||
if (auto integer = std::get_if<std::int64_t>(&value)) {
|
||||
return std::to_string(*integer);
|
||||
}
|
||||
return noto::detail::fail<std::string>(noto::detail::error_invalid_argument, "query did not return text");
|
||||
}
|
||||
|
||||
auto async_statement_reset(std::shared_ptr<noto::detail::statement_core> core, std::vector<noto::sqlite_value> params) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
if (!core) {
|
||||
co_return invalid_statement();
|
||||
}
|
||||
co_return co_await core->async_reset(std::move(params));
|
||||
}
|
||||
|
||||
auto async_statement_step(std::shared_ptr<noto::detail::statement_core> core) -> boost::asio::awaitable<std::expected<std::optional<std::vector<noto::sqlite_value>>, 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<noto::detail::statement_core> core, std::vector<noto::sqlite_value> params, noto::execute_options options) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
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<noto::detail::statement_core> core) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
if (!core) {
|
||||
co_return invalid_statement();
|
||||
}
|
||||
co_return co_await core->async_finalize();
|
||||
}
|
||||
|
||||
auto async_connection_connect(std::shared_ptr<noto::detail::connection_core> core) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
if (!core) {
|
||||
co_return invalid_connection();
|
||||
}
|
||||
co_return co_await core->async_connect();
|
||||
}
|
||||
|
||||
auto async_connection_close(std::shared_ptr<noto::detail::connection_core> core) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
if (!core) {
|
||||
co_return invalid_connection();
|
||||
}
|
||||
co_return co_await core->async_close();
|
||||
}
|
||||
|
||||
auto async_connection_execute(std::shared_ptr<noto::detail::connection_core> core, std::string sql, std::vector<noto::sqlite_value> params, noto::execute_options options) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
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<noto::detail::connection_core> core, std::string sql) -> boost::asio::awaitable<std::expected<noto::statement, modulus::error>> {
|
||||
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<noto::detail::connection_core> core, noto::transaction_mode mode) -> boost::asio::awaitable<std::expected<noto::transaction, modulus::error>> {
|
||||
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<noto::detail::connection_core> core, std::string path, noto::backup_options options) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<noto::detail::connection_core> core, std::string path, noto::backup_options options) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<noto::detail::connection_core> core, noto::export_options options) -> boost::asio::awaitable<std::expected<std::string, modulus::error>> {
|
||||
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<noto::detail::connection_core> core, std::string path, noto::export_options options) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
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<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto async_connection_drop_table(noto::connection connection, noto::table_name table, std::bool_t if_exists) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto async_connection_create_index(noto::connection connection, noto::index_definition definition) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto async_connection_drop_index(noto::connection connection, noto::index_definition definition) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto async_connection_integrity_check(noto::connection connection) -> boost::asio::awaitable<std::expected<std::vector<std::string>, modulus::error>> {
|
||||
auto result = co_await connection.async_execute("PRAGMA integrity_check");
|
||||
if (!result) {
|
||||
co_return std::unexpected(result.error());
|
||||
}
|
||||
auto messages = std::vector<std::string>{};
|
||||
for (auto const& row : result->rows) {
|
||||
if (!row.empty()) {
|
||||
if (auto text = std::get_if<std::string>(&row.front().value)) {
|
||||
messages.push_back(*text);
|
||||
}
|
||||
}
|
||||
}
|
||||
co_return messages;
|
||||
}
|
||||
|
||||
auto async_connection_quick_check(noto::connection connection) -> boost::asio::awaitable<std::expected<std::vector<std::string>, modulus::error>> {
|
||||
auto result = co_await connection.async_execute("PRAGMA quick_check");
|
||||
if (!result) {
|
||||
co_return std::unexpected(result.error());
|
||||
}
|
||||
auto messages = std::vector<std::string>{};
|
||||
for (auto const& row : result->rows) {
|
||||
if (!row.empty()) {
|
||||
if (auto text = std::get_if<std::string>(&row.front().value)) {
|
||||
messages.push_back(*text);
|
||||
}
|
||||
}
|
||||
}
|
||||
co_return messages;
|
||||
}
|
||||
|
||||
auto async_connection_foreign_key_check(noto::connection connection) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
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<std::expected<noto::query_result, modulus::error>> {
|
||||
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<std::bool_t, std::int64_t, std::string> value) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto async_connection_foreign_keys(noto::connection connection) -> boost::asio::awaitable<std::expected<std::bool_t, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
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<std::expected<std::chrono::milliseconds, modulus::error>> {
|
||||
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<noto::detail::connection_core> core, std::chrono::milliseconds timeout) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<std::expected<std::int64_t, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
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<std::expected<std::int64_t, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
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<std::expected<std::string, modulus::error>> {
|
||||
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<std::expected<noto::query_result, modulus::error>> {
|
||||
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<std::int64_t>(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<std::byte> current) : value{std::move(current)} {}
|
||||
|
||||
auto noto::sqlite_value::is_null() const -> std::bool_t {
|
||||
return std::holds_alternative<std::monostate>(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<noto::sqlite_value> params) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
return async_statement_reset(core_, std::move(params));
|
||||
}
|
||||
|
||||
auto noto::statement::async_step() -> boost::asio::awaitable<std::expected<std::optional<std::vector<noto::sqlite_value>>, modulus::error>> {
|
||||
return async_statement_step(core_);
|
||||
}
|
||||
|
||||
auto noto::statement::async_execute(std::vector<noto::sqlite_value> params) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
return async_execute(std::move(params), noto::execute_options{});
|
||||
}
|
||||
|
||||
auto noto::statement::async_execute(std::vector<noto::sqlite_value> params, noto::execute_options options) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
return async_statement_execute(core_, std::move(params), std::move(options));
|
||||
}
|
||||
|
||||
auto noto::statement::async_finalize() -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto core = core_;
|
||||
if (!core) {
|
||||
return async_status(invalid_statement());
|
||||
}
|
||||
return async_statement_finalize(std::move(core));
|
||||
}
|
||||
|
||||
noto::statement::statement(std::shared_ptr<noto::detail::statement_core> core) : core_{std::move(core)} {}
|
||||
|
||||
noto::connection::connection(boost::asio::any_io_executor executor, noto::connection_config config) : core_{std::make_shared<noto::detail::connection_core>(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<std::expected<void, modulus::error>> {
|
||||
assert(core_ != nullptr);
|
||||
modulus::log_debug("noto connection opening");
|
||||
return async_connection_connect(core_);
|
||||
}
|
||||
|
||||
auto noto::connection::async_close() -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
assert(core_ != nullptr);
|
||||
modulus::log_debug("noto connection closing");
|
||||
return async_connection_close(core_);
|
||||
}
|
||||
|
||||
auto noto::connection::async_execute(std::string sql, std::vector<noto::sqlite_value> params) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
return async_execute(std::move(sql), std::move(params), noto::execute_options{});
|
||||
}
|
||||
|
||||
auto noto::connection::async_execute(std::string sql, std::vector<noto::sqlite_value> params, noto::execute_options options) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
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<std::expected<noto::statement, modulus::error>> {
|
||||
return async_connection_prepare(core_, std::move(sql));
|
||||
}
|
||||
|
||||
auto noto::connection::async_begin_transaction(noto::transaction_mode mode) -> boost::asio::awaitable<std::expected<noto::transaction, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
return async_connection_create_index(noto::connection{core_}, std::move(definition));
|
||||
}
|
||||
|
||||
auto noto::connection::async_drop_index(noto::index_definition definition) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
return async_connection_drop_index(noto::connection{core_}, std::move(definition));
|
||||
}
|
||||
|
||||
auto noto::connection::async_backup_to_file(std::string path) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
return async_connection_restore_from_file(core_, std::move(path), std::move(options));
|
||||
}
|
||||
|
||||
auto noto::connection::async_export_sql() -> boost::asio::awaitable<std::expected<std::string, modulus::error>> {
|
||||
return async_export_sql(noto::export_options{});
|
||||
}
|
||||
|
||||
auto noto::connection::async_export_sql(noto::export_options options) -> boost::asio::awaitable<std::expected<std::string, modulus::error>> {
|
||||
return async_connection_export_sql(core_, std::move(options));
|
||||
}
|
||||
|
||||
auto noto::connection::async_export_sql_to_file(std::string path) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
return async_connection_export_sql_to_file(core_, std::move(path), std::move(options));
|
||||
}
|
||||
|
||||
auto noto::connection::async_integrity_check() -> boost::asio::awaitable<std::expected<std::vector<std::string>, modulus::error>> {
|
||||
return async_connection_integrity_check(noto::connection{core_});
|
||||
}
|
||||
|
||||
auto noto::connection::async_quick_check() -> boost::asio::awaitable<std::expected<std::vector<std::string>, modulus::error>> {
|
||||
return async_connection_quick_check(noto::connection{core_});
|
||||
}
|
||||
|
||||
auto noto::connection::async_foreign_key_check() -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
return async_connection_foreign_key_check(noto::connection{core_});
|
||||
}
|
||||
|
||||
auto noto::connection::async_get_pragma(std::string name) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
return async_connection_get_pragma(noto::connection{core_}, std::move(name));
|
||||
}
|
||||
|
||||
auto noto::connection::async_set_pragma(std::string name, std::variant<std::bool_t, std::int64_t, std::string> value) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
if (name == "busy_timeout") {
|
||||
if (auto timeout = std::get_if<std::int64_t>(&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<std::expected<std::bool_t, modulus::error>> {
|
||||
return async_connection_foreign_keys(noto::connection{core_});
|
||||
}
|
||||
|
||||
auto noto::connection::async_set_foreign_keys(std::bool_t enabled) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
return async_connection_set_foreign_keys(noto::connection{core_}, enabled);
|
||||
}
|
||||
|
||||
auto noto::connection::async_busy_timeout() -> boost::asio::awaitable<std::expected<std::chrono::milliseconds, modulus::error>> {
|
||||
return async_connection_busy_timeout(noto::connection{core_});
|
||||
}
|
||||
|
||||
auto noto::connection::async_set_busy_timeout(std::chrono::milliseconds timeout) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
return async_connection_set_busy_timeout(core_, timeout);
|
||||
}
|
||||
|
||||
auto noto::connection::async_user_version() -> boost::asio::awaitable<std::expected<std::int64_t, modulus::error>> {
|
||||
return async_connection_user_version(noto::connection{core_});
|
||||
}
|
||||
|
||||
auto noto::connection::async_set_user_version(std::int64_t version) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
return async_connection_set_user_version(noto::connection{core_}, version);
|
||||
}
|
||||
|
||||
auto noto::connection::async_application_id() -> boost::asio::awaitable<std::expected<std::int64_t, modulus::error>> {
|
||||
return async_connection_application_id(noto::connection{core_});
|
||||
}
|
||||
|
||||
auto noto::connection::async_set_application_id(std::int64_t id) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
return async_connection_set_application_id(noto::connection{core_}, id);
|
||||
}
|
||||
|
||||
auto noto::connection::async_journal_mode(std::string mode) -> boost::asio::awaitable<std::expected<std::string, modulus::error>> {
|
||||
return async_connection_journal_mode(noto::connection{core_}, std::move(mode));
|
||||
}
|
||||
|
||||
auto noto::connection::async_wal_checkpoint(std::string mode) -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
return async_connection_wal_checkpoint(noto::connection{core_}, std::move(mode));
|
||||
}
|
||||
|
||||
noto::connection::connection(std::shared_ptr<noto::detail::connection_core> core) : core_{std::move(core)} {}
|
||||
|
||||
auto noto::connection::core() const -> std::shared_ptr<noto::detail::connection_core> {
|
||||
return core_;
|
||||
}
|
||||
+2024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
#include <noto/detail/errors.hpp>
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
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<void, modulus::error> {
|
||||
return std::unexpected(std::move(error));
|
||||
}
|
||||
|
||||
auto noto::detail::fail_void(std::int32_t code, std::string message, modulus::source_location location) -> std::expected<void, modulus::error> {
|
||||
return noto::detail::fail_void(noto::detail::make_error(code, std::move(message), location));
|
||||
}
|
||||
|
||||
auto noto::detail::fail_void(std::expected<void, modulus::error> const& result) -> std::expected<void, modulus::error> {
|
||||
assert(!result);
|
||||
return noto::detail::fail_void(result.error());
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#include <noto/detail/sql.hpp>
|
||||
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <noto/detail/errors.hpp>
|
||||
|
||||
auto noto::detail::quote_sql_literal(std::string_view value, std::string_view label, std::bool_t allow_empty) -> std::expected<std::string, modulus::error> {
|
||||
if (value.empty() && !allow_empty) {
|
||||
return noto::detail::fail<std::string>(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<std::string>(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));
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#include <noto/errors.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <noto/detail/errors.hpp>
|
||||
|
||||
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<void, modulus::error> {
|
||||
if (code == SQLITE_OK || code == SQLITE_ROW || code == SQLITE_DONE) {
|
||||
return {};
|
||||
}
|
||||
return noto::detail::fail_void(noto::error_from(code, database, operation));
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
#include <noto/migration.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <noto/detail/errors.hpp>
|
||||
#include <noto/detail/sql.hpp>
|
||||
#include <noto/connection.hpp>
|
||||
#include <noto/schema.hpp>
|
||||
#include <noto/transaction.hpp>
|
||||
#include <cstdint>
|
||||
|
||||
namespace {
|
||||
|
||||
auto invalid(std::string message) -> std::expected<void, modulus::error> {
|
||||
return noto::detail::fail_void(noto::detail::error_invalid_argument, std::move(message));
|
||||
}
|
||||
|
||||
auto internal(std::string message) -> std::expected<void, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<noto::migration> migrations, std::bool_t require_up) -> std::expected<std::vector<noto::migration>, modulus::error> {
|
||||
auto ids = std::set<std::string>{};
|
||||
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<std::string>(&value.value)) {
|
||||
return *text;
|
||||
}
|
||||
if (auto integer = std::get_if<std::int64_t>(&value.value)) {
|
||||
return std::to_string(*integer);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
auto execute_status(noto::connection& connection, std::string sql) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto result = co_await connection.async_execute(std::move(sql));
|
||||
if (!result) {
|
||||
co_return std::unexpected(result.error());
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto ensure_migration_table(noto::connection& connection, noto::migration_options const& options) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<std::expected<std::vector<noto::migration_record>, 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<noto::migration_record>{};
|
||||
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<noto::migration_record> 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<void, modulus::error> status) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
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<std::expected<std::vector<noto::migration_record>, 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<noto::migration> migrations) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<std::bool_t>(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<noto::migration> migrations, std::string target_id) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<std::string, noto::migration>{};
|
||||
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();
|
||||
}
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
#include <noto/schema.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <noto/detail/errors.hpp>
|
||||
#include <noto/detail/sql.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
auto invalid_sql(std::string message) -> std::expected<std::string, modulus::error> {
|
||||
return noto::detail::fail<std::string>(noto::detail::error_invalid_argument, std::move(message));
|
||||
}
|
||||
|
||||
template <typename VALUE>
|
||||
auto invalid_value(std::string message) -> std::expected<VALUE, modulus::error> {
|
||||
return noto::detail::fail<VALUE>(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<void, modulus::error> {
|
||||
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<void, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string> const& identifiers) -> std::expected<void, modulus::error> {
|
||||
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<void, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string> const& columns) -> std::expected<std::string, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<noto::trusted_sql_fragment, modulus::error> {
|
||||
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<noto::column_type, modulus::error> {
|
||||
if (length == 0) {
|
||||
return invalid_value<noto::column_type>("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<noto::column_type, modulus::error> {
|
||||
if (precision == 0) {
|
||||
return invalid_value<noto::column_type>("DECIMAL precision must be positive");
|
||||
}
|
||||
if (scale > precision) {
|
||||
return invalid_value<noto::column_type>("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<noto::default_value, modulus::error> {
|
||||
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<noto::default_value, modulus::error> {
|
||||
if (!std::isfinite(value)) {
|
||||
return invalid_value<noto::default_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<std::string, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string>{};
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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<std::string, modulus::error> {
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#include <noto/transaction.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <noto/detail/errors.hpp>
|
||||
|
||||
#include <noto/detail/core.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
auto inactive_transaction() -> std::expected<void, modulus::error> {
|
||||
return noto::detail::fail_void(noto::detail::error_invalid_argument, "transaction is not active");
|
||||
}
|
||||
|
||||
auto async_status(std::expected<void, modulus::error> status) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
co_return status;
|
||||
}
|
||||
|
||||
auto finish_started_transaction(std::shared_ptr<noto::detail::transaction_state>& 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<noto::detail::transaction_state> state) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<noto::detail::transaction_state> state) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<noto::detail::transaction_state> state) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<noto::detail::transaction_state> state) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
return async_commit_impl(state_);
|
||||
}
|
||||
|
||||
auto noto::transaction::async_rollback() -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
return async_rollback_impl(state_);
|
||||
}
|
||||
|
||||
noto::transaction::transaction(std::shared_ptr<noto::detail::transaction_state> state) : state_{std::move(state)} {}
|
||||
|
||||
noto::transaction::transaction(std::shared_ptr<noto::detail::connection_core> core, std::function<void()> on_finish) : state_{std::make_shared<noto::detail::transaction_state>(std::move(core), std::move(on_finish))} {}
|
||||
Executable
+26
@@ -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
|
||||
$<$<CONFIG:Debug>:NOTO_EXPECT_DEBUG_ASSERTS>
|
||||
$<$<NOT:$<CONFIG:Debug>>: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
|
||||
)
|
||||
+1149
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
#define BOOST_TEST_MODULE noto
|
||||
#include <boost/test/included/unit_test.hpp>
|
||||
Reference in New Issue
Block a user