Initial public khodo snapshot
publish / publish (push) Successful in 26m25s

This commit is contained in:
khodowany
2026-09-04 19:30:11 -05:00
commit fc0bad81c4
31 changed files with 6668 additions and 0 deletions
+105
View File
@@ -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
View File
@@ -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
View File
@@ -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
)
+21
View File
@@ -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.
+43
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
find_dependency(modulus CONFIG REQUIRED)
include("${CMAKE_CURRENT_LIST_DIR}/noto-targets.cmake")
+107
View File
@@ -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",
]
+8
View File
@@ -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>
+48
View File
@@ -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;
}
+199
View File
@@ -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));
}
+83
View File
@@ -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};
};
}
+59
View File
@@ -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));
}
+13
View File
@@ -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>;
}
+16
View File
@@ -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>;
}
+47
View File
@@ -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_{};
};
}
+146
View File
@@ -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;
}
+47
View File
@@ -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_{};
};
}
+11
View File
@@ -0,0 +1,11 @@
{
"package": {
"name": "noto"
},
"tooling": {
"remote": {
"name": "khodo",
"url": "https://git.thyme-labs.com/api/packages/khodo/conan"
}
}
}
Executable
+367
View File
@@ -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
View File
@@ -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),
},
};
}
+593
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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());
}
+32
View File
@@ -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));
}
+79
View File
@@ -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));
}
+322
View File
@@ -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, {}, &noto::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<>{}, &noto::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
View File
@@ -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";
}
+111
View File
@@ -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))} {}
+26
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
#pragma once
#define BOOST_TEST_MODULE noto
#include <boost/test/included/unit_test.hpp>