From 08c6376fef4056e83dc1617d5e6f27b4680aee6d Mon Sep 17 00:00:00 2001 From: Eneko Gonzalez Date: Thu, 30 Apr 2026 22:00:52 +0000 Subject: [PATCH 1/5] feat(chronosql): add SQL-style plugin for ChronoLog (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChronoSQL is a new client-side plugin that exposes chronicles and stories through a SQL-shaped interface targeting YCSB-compatible workloads. No client changes are required; the plugin is built entirely on top of the existing ChronoLog client API. Storage model: - database ↔ chronicle (ChronoSQLChronicle by default) - table ↔ story (one per table) - row ↔ event whose log_record is a JSON object of column values - schema/table-list ↔ a reserved __chronosql_metadata story whose events encode CREATE/DROP operations and are replayed on first use Two coupled APIs: - Programmatic: createTable / dropTable / listTables / insert / selectByKey / selectRange / selectAll - SQL string facade: ChronoSQL::execute(sql) parses a YCSB-shaped subset (CREATE TABLE, DROP TABLE, INSERT, SELECT with WHERE col=val or WHERE __ts BETWEEN n AND n) via a hand-rolled tokenizer + recursive descent parser The implicit __ts pseudo-column maps directly to the ChronoLog event timestamp and lowers __ts BETWEEN to ReplayStory ranges. Constructors mirror the chronokvs pattern: default + (config_path) so the plugin can target distributed deployments via a ChronoLog client config file. Failures to load the config throw std::runtime_error before any RPC connection is attempted. Tests: - chronosql_unit_test: 27 cases covering parser, JSON row codec, and metadata replay/idempotency - chronosql_config_test: throws on missing/section-less config files - chronosql_integration_test: MANUAL TRUE end-to-end YCSB-shaped workload (CREATE TABLE / INSERT x100 / SELECT * / SELECT WHERE = / SELECT WHERE __ts BETWEEN); not run in CI Examples: - chrono-chronosql-example-writer / -reader, both accept --config/-c --- Plugins/CMakeLists.txt | 1 + Plugins/chronosql/CMakeLists.txt | 72 +++ Plugins/chronosql/examples/CMakeLists.txt | 30 + .../examples/chronosql_reader_example.cpp | 36 ++ .../examples/chronosql_writer_example.cpp | 34 ++ Plugins/chronosql/include/chronosql.h | 136 +++++ Plugins/chronosql/include/chronosql_logger.h | 134 +++++ Plugins/chronosql/include/chronosql_types.h | 78 +++ Plugins/chronosql/src/chronosql.cpp | 229 ++++++++ .../src/chronosql_client_adapter.cpp | 165 ++++++ .../chronosql/src/chronosql_client_adapter.h | 71 +++ Plugins/chronosql/src/chronosql_mapper.cpp | 224 ++++++++ Plugins/chronosql/src/chronosql_mapper.h | 64 +++ Plugins/chronosql/src/chronosql_metadata.cpp | 178 ++++++ Plugins/chronosql/src/chronosql_metadata.h | 62 +++ Plugins/chronosql/src/chronosql_row_codec.cpp | 61 ++ Plugins/chronosql/src/chronosql_row_codec.h | 29 + .../chronosql/src/chronosql_sql_parser.cpp | 521 ++++++++++++++++++ Plugins/chronosql/src/chronosql_sql_parser.h | 70 +++ test/integration/CMakeLists.txt | 1 + test/integration/chronosql/CMakeLists.txt | 39 ++ .../chronosql/chronosql_integration_test.cpp | 100 ++++ test/unit/CMakeLists.txt | 1 + test/unit/chronosql/CMakeLists.txt | 54 ++ test/unit/chronosql/chronosql_config_test.cpp | 28 + .../chronosql/chronosql_metadata_test.cpp | 75 +++ .../chronosql/chronosql_row_codec_test.cpp | 43 ++ .../chronosql/chronosql_sql_parser_test.cpp | 137 +++++ 28 files changed, 2673 insertions(+) create mode 100644 Plugins/chronosql/CMakeLists.txt create mode 100644 Plugins/chronosql/examples/CMakeLists.txt create mode 100644 Plugins/chronosql/examples/chronosql_reader_example.cpp create mode 100644 Plugins/chronosql/examples/chronosql_writer_example.cpp create mode 100644 Plugins/chronosql/include/chronosql.h create mode 100644 Plugins/chronosql/include/chronosql_logger.h create mode 100644 Plugins/chronosql/include/chronosql_types.h create mode 100644 Plugins/chronosql/src/chronosql.cpp create mode 100644 Plugins/chronosql/src/chronosql_client_adapter.cpp create mode 100644 Plugins/chronosql/src/chronosql_client_adapter.h create mode 100644 Plugins/chronosql/src/chronosql_mapper.cpp create mode 100644 Plugins/chronosql/src/chronosql_mapper.h create mode 100644 Plugins/chronosql/src/chronosql_metadata.cpp create mode 100644 Plugins/chronosql/src/chronosql_metadata.h create mode 100644 Plugins/chronosql/src/chronosql_row_codec.cpp create mode 100644 Plugins/chronosql/src/chronosql_row_codec.h create mode 100644 Plugins/chronosql/src/chronosql_sql_parser.cpp create mode 100644 Plugins/chronosql/src/chronosql_sql_parser.h create mode 100644 test/integration/chronosql/CMakeLists.txt create mode 100644 test/integration/chronosql/chronosql_integration_test.cpp create mode 100644 test/unit/chronosql/CMakeLists.txt create mode 100644 test/unit/chronosql/chronosql_config_test.cpp create mode 100644 test/unit/chronosql/chronosql_metadata_test.cpp create mode 100644 test/unit/chronosql/chronosql_row_codec_test.cpp create mode 100644 test/unit/chronosql/chronosql_sql_parser_test.cpp diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index dba35fe07..30f70a7e6 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -1,6 +1,7 @@ cmake_minimum_required(VERSION 3.25) add_subdirectory(chronokvs) +add_subdirectory(chronosql) # TODO(#517): Re-enable ChronoStream plugin build/install once the plugin is cleaned up # and properly integrated/packaged. It is temporarily disabled to reduce noise and diff --git a/Plugins/chronosql/CMakeLists.txt b/Plugins/chronosql/CMakeLists.txt new file mode 100644 index 000000000..f9cdf4091 --- /dev/null +++ b/Plugins/chronosql/CMakeLists.txt @@ -0,0 +1,72 @@ +cmake_minimum_required(VERSION 3.25) + +project(chronosql + VERSION 0.1.0 + DESCRIPTION "ChronoSQL Plugin for ChronoLog" + LANGUAGES CXX +) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +include(GNUInstallDirs) + +add_library(chronosql + src/chronosql.cpp + src/chronosql_mapper.cpp + src/chronosql_client_adapter.cpp + src/chronosql_metadata.cpp + src/chronosql_row_codec.cpp + src/chronosql_sql_parser.cpp +) + +target_include_directories(chronosql + PUBLIC + $ + $ + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src +) + +# Public headers for installation +file(GLOB HEADER_FILES "${CMAKE_CURRENT_SOURCE_DIR}/include/*.h") +set_target_properties(chronosql PROPERTIES + PUBLIC_HEADER "${HEADER_FILES}" +) + +set_target_properties(chronosql PROPERTIES + VERSION ${CHRONOLOG_PACKAGE_VERSION} + SOVERSION ${CHRONOLOG_VERSION_MAJOR} +) + +# Bring in chronolog_client's interface headers (json-c, ClientConfiguration, ...). +target_include_directories(chronosql PRIVATE + $ +) + +find_package(Threads REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(JSON-C REQUIRED json-c) + +target_include_directories(chronosql PRIVATE ${JSON-C_INCLUDE_DIRS}) +target_link_directories(chronosql PRIVATE ${JSON-C_LIBRARY_DIRS}) + +target_link_libraries(chronosql + PRIVATE + chronolog_client + ${JSON-C_LIBRARIES} + rt + PUBLIC + Threads::Threads +) + +option(CHRONOSQL_BUILD_EXAMPLES "Build ChronoSQL examples" ON) +if(CHRONOSQL_BUILD_EXAMPLES) + add_subdirectory(examples) +endif() + +chronolog_install_target(chronosql) + +message(STATUS "ChronoSQL: superbuild mode enabled") diff --git a/Plugins/chronosql/examples/CMakeLists.txt b/Plugins/chronosql/examples/CMakeLists.txt new file mode 100644 index 000000000..c31537121 --- /dev/null +++ b/Plugins/chronosql/examples/CMakeLists.txt @@ -0,0 +1,30 @@ +# ChronoSQL runnable examples (installed as chrono-chronosql-example-) +add_executable(chronosql_writer_example chronosql_writer_example.cpp) +add_executable(chronosql_reader_example chronosql_reader_example.cpp) + +target_link_libraries(chronosql_writer_example PRIVATE chronosql) +target_link_libraries(chronosql_reader_example PRIVATE chronosql) + +# cmd_arg_parse.h lives in chronolog_client's public include dir. chronosql +# consumes chronolog_client privately, so pull its include interface in +# directly here for the examples that use the helper. +target_include_directories(chronosql_writer_example PRIVATE + $ +) +target_include_directories(chronosql_reader_example PRIVATE + $ +) + +set_target_properties(chronosql_writer_example PROPERTIES + OUTPUT_NAME chrono-chronosql-example-writer + BUILD_WITH_INSTALL_RPATH TRUE + SKIP_BUILD_RPATH TRUE +) +set_target_properties(chronosql_reader_example PROPERTIES + OUTPUT_NAME chrono-chronosql-example-reader + BUILD_WITH_INSTALL_RPATH TRUE + SKIP_BUILD_RPATH TRUE +) + +chronolog_install_target(chronosql_writer_example DESTINATION examples) +chronolog_install_target(chronosql_reader_example DESTINATION examples) diff --git a/Plugins/chronosql/examples/chronosql_reader_example.cpp b/Plugins/chronosql/examples/chronosql_reader_example.cpp new file mode 100644 index 000000000..17040f1c6 --- /dev/null +++ b/Plugins/chronosql/examples/chronosql_reader_example.cpp @@ -0,0 +1,36 @@ +#include +#include +#include + +#include + +#include "chronosql.h" + +int main(int argc, char** argv) +{ + std::string conf_file_path = parse_conf_path_arg(argc, argv); + + chronosql::ChronoSQL db = conf_file_path.empty() ? chronosql::ChronoSQL() : chronosql::ChronoSQL(conf_file_path); + + auto schema = db.getSchema("users"); + if(!schema) + { + std::cerr << "Table 'users' not found. Run the writer example first.\n"; + return EXIT_FAILURE; + } + + auto result = db.execute("SELECT * FROM users"); + std::cout << "Found " << result.rows.size() << " rows in 'users':\n"; + for(const auto& row: result.rows) + { + std::cout << " ts=" << row.timestamp; + for(const auto& col: result.columns) + { + auto it = row.values.find(col); + std::cout << " " << col << "=" << (it == row.values.end() ? "NULL" : it->second); + } + std::cout << "\n"; + } + + return EXIT_SUCCESS; +} diff --git a/Plugins/chronosql/examples/chronosql_writer_example.cpp b/Plugins/chronosql/examples/chronosql_writer_example.cpp new file mode 100644 index 000000000..1ddc3a118 --- /dev/null +++ b/Plugins/chronosql/examples/chronosql_writer_example.cpp @@ -0,0 +1,34 @@ +#include +#include +#include +#include + +#include + +#include "chronosql.h" + +int main(int argc, char** argv) +{ + // Optional ChronoLog client config file (-c/--config). Same convention as + // the ChronoKVS examples; when omitted, ChronoSQL uses the localhost + // defaults. + std::string conf_file_path = parse_conf_path_arg(argc, argv); + + chronosql::ChronoSQL db = conf_file_path.empty() ? chronosql::ChronoSQL() : chronosql::ChronoSQL(conf_file_path); + + // Programmatic API path: create a small "users" table. + if(!db.getSchema("users").has_value()) + { + db.createTable("users", {{"id", chronosql::ColumnType::INT}, {"name", chronosql::ColumnType::STRING}}); + } + + // SQL string facade path: insert via execute(). + auto r1 = db.execute("INSERT INTO users (id, name) VALUES (1, 'alice')"); + auto r2 = db.execute("INSERT INTO users VALUES (2, 'bob')"); + + std::cout << "Inserted ts=" << r1.last_insert_timestamp.value_or(0) + << " and ts=" << r2.last_insert_timestamp.value_or(0) << "\n"; + + db.flush(); + return EXIT_SUCCESS; +} diff --git a/Plugins/chronosql/include/chronosql.h b/Plugins/chronosql/include/chronosql.h new file mode 100644 index 000000000..fbc59f195 --- /dev/null +++ b/Plugins/chronosql/include/chronosql.h @@ -0,0 +1,136 @@ +#ifndef CHRONOSQL_H_ +#define CHRONOSQL_H_ + +#include +#include +#include +#include +#include +#include + +#include "chronosql_logger.h" +#include "chronosql_types.h" + +namespace chronosql +{ + +class ChronoSQLMapper; + +/** + * @brief SQL-style facade over a single ChronoLog chronicle. + * + * A ChronoSQL "database" is one chronicle. Each table is one story inside that + * chronicle, and each row is one event whose `log_record` is a JSON object of + * column values. A reserved metadata story persists CREATE/DROP TABLE + * operations so the schema and table list are recoverable across processes. + * + * The implicit `__ts` pseudo-column exposes the ChronoLog event timestamp and + * can be used in time-range queries (e.g. WHERE __ts BETWEEN a AND b). + */ +class ChronoSQL +{ +private: + std::unique_ptr mapper; + LogLevel logLevel_; + +public: + /** + * @brief Construct a ChronoSQL instance using built-in defaults. + * + * Uses the localhost ChronoLog client defaults and connects to the default + * chronicle. + */ + explicit ChronoSQL(LogLevel level = getDefaultLogLevel()); + + /** + * @brief Construct a ChronoSQL instance loading a ChronoLog client config. + * + * @param config_path + * Path to a ChronoLog client configuration JSON file. Empty means + * "use defaults" (same as the no-arg constructor). + * @param level + * The logging level. + * + * @throws std::runtime_error if the config file cannot be loaded or the + * ChronoLog client fails to connect. + */ + explicit ChronoSQL(const std::string& config_path, LogLevel level = getDefaultLogLevel()); + + ~ChronoSQL(); + + LogLevel getLogLevel() const { return logLevel_; } + + // ---- Programmatic engine API ------------------------------------------ + + /** + * @brief Create a new table with the given schema. + * + * @throws std::invalid_argument on empty/duplicate columns or reserved + * column names (any name starting with `__`). + * @throws std::runtime_error if the table already exists or the metadata + * append fails. + */ + void createTable(const std::string& name, const std::vector& columns); + + /** + * @brief Drop a table. The underlying story is not destroyed; only the + * metadata mapping is removed (an event log is append-only). + * + * @throws std::runtime_error if the table does not exist. + */ + void dropTable(const std::string& name); + + /// List all currently known table names (in CREATE order). + std::vector listTables(); + + /// Return the schema for a table, or std::nullopt if unknown. + std::optional getSchema(const std::string& table); + + /** + * @brief Insert a row into @p table. Column names not in the schema are + * rejected. Missing columns are treated as NULL (omitted from the + * persisted JSON). + * + * @return The ChronoLog event timestamp assigned to this row. + */ + std::uint64_t insert(const std::string& table, const std::map& row); + + /// Equality lookup on an arbitrary column. O(events) — no auxiliary index. + std::vector selectByKey(const std::string& table, const std::string& column, const std::string& value); + + /// Time-range scan on the implicit `__ts` column. + /// Returns rows with `__ts` in [start_ts, end_ts). + std::vector selectRange(const std::string& table, std::uint64_t start_ts, std::uint64_t end_ts); + + /// Full-table scan. + std::vector selectAll(const std::string& table); + + // ---- SQL string facade ------------------------------------------------ + + /** + * @brief Parse and execute a single SQL statement. + * + * Supported grammar (case-insensitive keywords): + * - CREATE TABLE name (col [type], col [type], ...) + * - DROP TABLE name + * - INSERT INTO name [(col, col, ...)] VALUES (v, v, ...) + * - SELECT * | col, ... FROM name + * [WHERE col = value | WHERE __ts BETWEEN n AND n] + * + * Types: INT | DOUBLE | STRING | BOOL (synonyms: VARCHAR, TEXT → STRING). + * + * @throws std::invalid_argument on parse errors. + * @throws std::runtime_error on execution errors. + */ + ResultSet execute(const std::string& sql); + + /** + * @brief Flush all cached write story handles, releasing them so writes + * become visible to subsequent reads. Equivalent to the chronokvs flush(). + */ + void flush(); +}; + +} // namespace chronosql + +#endif // CHRONOSQL_H_ diff --git a/Plugins/chronosql/include/chronosql_logger.h b/Plugins/chronosql/include/chronosql_logger.h new file mode 100644 index 000000000..2953710a2 --- /dev/null +++ b/Plugins/chronosql/include/chronosql_logger.h @@ -0,0 +1,134 @@ +#ifndef CHRONOSQL_LOGGER_H +#define CHRONOSQL_LOGGER_H + +#include +#include +#include +#include + +namespace chronosql +{ + +enum class LogLevel : std::uint8_t +{ + TRACE = 0, + DEBUG = 1, + INFO = 2, + WARNING = 3, + ERROR = 4, + CRITICAL = 5, + OFF = 6 +}; + +inline LogLevel getDefaultLogLevel() +{ +#ifdef NDEBUG + return LogLevel::ERROR; +#else + return LogLevel::DEBUG; +#endif +} + +inline const char* logLevelToString(LogLevel level) +{ + switch(level) + { + case LogLevel::TRACE: + return "TRACE"; + case LogLevel::DEBUG: + return "DEBUG"; + case LogLevel::INFO: + return "INFO"; + case LogLevel::WARNING: + return "WARNING"; + case LogLevel::ERROR: + return "ERROR"; + case LogLevel::CRITICAL: + return "CRITICAL"; + case LogLevel::OFF: + return "OFF"; + default: + return "UNKNOWN"; + } +} + +inline void log_message(LogLevel level, const std::string& message) +{ + std::cerr << "[ChronoSQL][" << logLevelToString(level) << "] " << message << std::endl; +} + +template +inline std::string format_log_message(const char* fmt, Args&&... args) +{ + std::ostringstream oss; + oss << fmt; + ((oss << " " << std::forward(args)), ...); + return oss.str(); +} + +inline std::string format_log_message(const char* msg) { return std::string(msg); } + +template +inline std::string format_log_message(const char* fmt, T&& arg) +{ + std::ostringstream oss; + oss << fmt << arg; + return oss.str(); +} + +} // namespace chronosql + +#ifdef NDEBUG +#define CHRONOSQL_TRACE(level, ...) +#define CHRONOSQL_DEBUG(level, ...) +#else +#define CHRONOSQL_TRACE(level, ...) \ + do { \ + if((level) <= chronosql::LogLevel::TRACE) \ + { \ + chronosql::log_message(chronosql::LogLevel::TRACE, chronosql::format_log_message(__VA_ARGS__)); \ + } \ + } while(0) + +#define CHRONOSQL_DEBUG(level, ...) \ + do { \ + if((level) <= chronosql::LogLevel::DEBUG) \ + { \ + chronosql::log_message(chronosql::LogLevel::DEBUG, chronosql::format_log_message(__VA_ARGS__)); \ + } \ + } while(0) +#endif + +#define CHRONOSQL_INFO(level, ...) \ + do { \ + if((level) <= chronosql::LogLevel::INFO) \ + { \ + chronosql::log_message(chronosql::LogLevel::INFO, chronosql::format_log_message(__VA_ARGS__)); \ + } \ + } while(0) + +#define CHRONOSQL_WARNING(level, ...) \ + do { \ + if((level) <= chronosql::LogLevel::WARNING) \ + { \ + chronosql::log_message(chronosql::LogLevel::WARNING, chronosql::format_log_message(__VA_ARGS__)); \ + } \ + } while(0) + +#define CHRONOSQL_ERROR(level, ...) \ + do { \ + if((level) <= chronosql::LogLevel::ERROR) \ + { \ + chronosql::log_message(chronosql::LogLevel::ERROR, chronosql::format_log_message(__VA_ARGS__)); \ + } \ + } while(0) + +#define CHRONOSQL_CRITICAL(level, ...) \ + do { \ + if((level) <= chronosql::LogLevel::CRITICAL) \ + { \ + chronosql::log_message(chronosql::LogLevel::CRITICAL, chronosql::format_log_message(__VA_ARGS__)); \ + } \ + } while(0) + +#endif // CHRONOSQL_LOGGER_H diff --git a/Plugins/chronosql/include/chronosql_types.h b/Plugins/chronosql/include/chronosql_types.h new file mode 100644 index 000000000..51e103574 --- /dev/null +++ b/Plugins/chronosql/include/chronosql_types.h @@ -0,0 +1,78 @@ +#ifndef CHRONOSQL_TYPES_H_ +#define CHRONOSQL_TYPES_H_ + +#include +#include +#include +#include +#include + +namespace chronosql +{ + +/** + * @brief Declared column types. Stored values are always strings; types are + * advisory metadata that the engine records on CREATE TABLE and surfaces back + * to the caller. v0.1 does not enforce types at INSERT time beyond column-name + * validation. + */ +enum class ColumnType : std::uint8_t +{ + INT, + DOUBLE, + STRING, + BOOL +}; + +const char* columnTypeToString(ColumnType t); +std::optional columnTypeFromString(const std::string& s); + +struct Column +{ + std::string name; + ColumnType type{ColumnType::STRING}; +}; + +/** + * @brief Schema for a single table: ordered list of columns. + * + * Columns named with the reserved `__` prefix are not allowed. The implicit + * `__ts` pseudo-column (event timestamp) is always available for filtering + * but is never part of the persisted row payload. + */ +struct Schema +{ + std::string table; + std::vector columns; +}; + +/** + * @brief A single row materialized from a table. + * + * `timestamp` is the ChronoLog event timestamp (the implicit `__ts` column). + * `values` holds string-form column values keyed by column name; columns + * absent from the map are treated as NULL. + */ +struct Row +{ + std::uint64_t timestamp{0}; + std::map values; +}; + +/** + * @brief Result of executing a SQL statement via execute(). + * + * For SELECT, `columns` is the projected column order and `rows` holds the + * matching rows. For INSERT, `last_insert_timestamp` is the event timestamp + * assigned by ChronoLog. For CREATE/DROP TABLE, the result carries no rows. + */ +struct ResultSet +{ + std::vector columns; + std::vector rows; + std::optional last_insert_timestamp; +}; + +} // namespace chronosql + +#endif // CHRONOSQL_TYPES_H_ diff --git a/Plugins/chronosql/src/chronosql.cpp b/Plugins/chronosql/src/chronosql.cpp new file mode 100644 index 000000000..d658e1a9f --- /dev/null +++ b/Plugins/chronosql/src/chronosql.cpp @@ -0,0 +1,229 @@ +#include "chronosql.h" + +#include +#include +#include +#include +#include + +#include "chronosql_mapper.h" +#include "chronosql_sql_parser.h" + +namespace chronosql +{ + +const char* columnTypeToString(ColumnType t) +{ + switch(t) + { + case ColumnType::INT: + return "INT"; + case ColumnType::DOUBLE: + return "DOUBLE"; + case ColumnType::STRING: + return "STRING"; + case ColumnType::BOOL: + return "BOOL"; + } + return "STRING"; +} + +std::optional columnTypeFromString(const std::string& s) +{ + std::string up; + up.reserve(s.size()); + for(char c: s) { up += static_cast(std::toupper(static_cast(c))); } + if(up == "INT" || up == "INTEGER" || up == "BIGINT") + { + return ColumnType::INT; + } + if(up == "DOUBLE" || up == "FLOAT" || up == "REAL") + { + return ColumnType::DOUBLE; + } + if(up == "STRING" || up == "TEXT" || up == "VARCHAR" || up == "CHAR") + { + return ColumnType::STRING; + } + if(up == "BOOL" || up == "BOOLEAN") + { + return ColumnType::BOOL; + } + return std::nullopt; +} + +ChronoSQL::ChronoSQL(LogLevel level) + : mapper(std::make_unique(level)) + , logLevel_(level) +{} + +ChronoSQL::ChronoSQL(const std::string& config_path, LogLevel level) + : mapper(std::make_unique(config_path, level)) + , logLevel_(level) +{} + +ChronoSQL::~ChronoSQL() = default; + +void ChronoSQL::createTable(const std::string& name, const std::vector& columns) +{ + mapper->createTable(name, columns); +} + +void ChronoSQL::dropTable(const std::string& name) { mapper->dropTable(name); } + +std::vector ChronoSQL::listTables() { return mapper->listTables(); } + +std::optional ChronoSQL::getSchema(const std::string& table) { return mapper->getSchema(table); } + +std::uint64_t ChronoSQL::insert(const std::string& table, const std::map& row) +{ + return mapper->insert(table, row); +} + +std::vector ChronoSQL::selectByKey(const std::string& table, const std::string& column, const std::string& value) +{ + return mapper->selectByKey(table, column, value); +} + +std::vector ChronoSQL::selectRange(const std::string& table, std::uint64_t start_ts, std::uint64_t end_ts) +{ + return mapper->selectRange(table, start_ts, end_ts); +} + +std::vector ChronoSQL::selectAll(const std::string& table) { return mapper->selectAll(table); } + +void ChronoSQL::flush() { mapper->flush(); } + +namespace +{ + +std::vector projectionColumns(const ParsedSelect& sel, const Schema& schema) +{ + if(sel.projection.empty()) + { + std::vector all; + all.reserve(schema.columns.size()); + for(const auto& c: schema.columns) { all.push_back(c.name); } + return all; + } + return sel.projection; +} + +std::vector projectRows(const std::vector& cols, std::vector&& rows) +{ + bool wants_ts = std::find(cols.begin(), cols.end(), "__ts") != cols.end(); + for(auto& r: rows) + { + std::map kept; + for(const auto& c: cols) + { + if(c == "__ts") + { + continue; // already on Row::timestamp + } + auto it = r.values.find(c); + if(it != r.values.end()) + { + kept.emplace(c, it->second); + } + } + r.values = std::move(kept); + (void)wants_ts; + } + return std::move(rows); +} + +} // namespace + +ResultSet ChronoSQL::execute(const std::string& sql) +{ + ParsedStatement stmt = parseStatement(sql); + ResultSet result; + + if(auto* p = std::get_if(&stmt)) + { + mapper->createTable(p->table, p->columns); + return result; + } + if(auto* p = std::get_if(&stmt)) + { + mapper->dropTable(p->table); + return result; + } + if(auto* p = std::get_if(&stmt)) + { + auto schema = mapper->getSchema(p->table); + if(!schema) + { + throw std::runtime_error("ChronoSQL: cannot insert into unknown table '" + p->table + "'"); + } + std::vector col_names; + if(p->columns.empty()) + { + for(const auto& c: schema->columns) { col_names.push_back(c.name); } + } + else + { + col_names = p->columns; + } + if(col_names.size() != p->values.size()) + { + throw std::invalid_argument("ChronoSQL: INSERT column count does not match value count"); + } + std::map row; + for(std::size_t i = 0; i < col_names.size(); ++i) + { + if(p->is_null[i]) + { + continue; // NULL → omit column + } + row.emplace(col_names[i], p->values[i]); + } + std::uint64_t ts = mapper->insert(p->table, row); + result.last_insert_timestamp = ts; + return result; + } + if(auto* p = std::get_if(&stmt)) + { + auto schema = mapper->getSchema(p->table); + if(!schema) + { + throw std::runtime_error("ChronoSQL: cannot SELECT from unknown table '" + p->table + "'"); + } + std::vector rows; + if(std::holds_alternative(p->where)) + { + auto w = std::get(p->where); + // BETWEEN is inclusive in SQL; ReplayStory range is half-open. + std::uint64_t end = (w.hi == UINT64_MAX) ? UINT64_MAX : w.hi + 1; + rows = mapper->selectRange(p->table, w.lo, end); + } + else if(std::holds_alternative(p->where)) + { + auto w = std::get(p->where); + if(w.is_null) + { + rows = mapper->selectAll(p->table); + rows.erase(std::remove_if(rows.begin(), + rows.end(), + [&](const Row& r) { return r.values.find(w.column) != r.values.end(); }), + rows.end()); + } + else + { + rows = mapper->selectByKey(p->table, w.column, w.value); + } + } + else + { + rows = mapper->selectAll(p->table); + } + result.columns = projectionColumns(*p, *schema); + result.rows = projectRows(result.columns, std::move(rows)); + return result; + } + + throw std::invalid_argument("ChronoSQL: unsupported statement"); +} + +} // namespace chronosql diff --git a/Plugins/chronosql/src/chronosql_client_adapter.cpp b/Plugins/chronosql/src/chronosql_client_adapter.cpp new file mode 100644 index 000000000..580f87c30 --- /dev/null +++ b/Plugins/chronosql/src/chronosql_client_adapter.cpp @@ -0,0 +1,165 @@ +#include "chronosql_client_adapter.h" + +#include +#include + +#include + +namespace chronosql +{ + +namespace +{ +int DEFAULT_FLAGS = 0; +} + +ChronoSQLClientAdapter::ChronoSQLClientAdapter(const std::string& chronicle_name, LogLevel level) + : chronicle_(chronicle_name) + , logLevel_(level) +{ + chronolog::ClientConfiguration confManager; + initialize(confManager); +} + +ChronoSQLClientAdapter::ChronoSQLClientAdapter(const std::string& chronicle_name, + const std::string& config_path, + LogLevel level) + : chronicle_(chronicle_name) + , logLevel_(level) +{ + chronolog::ClientConfiguration confManager; + if(!config_path.empty()) + { + CHRONOSQL_INFO(logLevel_, "Loading ChronoLog client configuration from", config_path); + if(!confManager.load_from_file(config_path)) + { + CHRONOSQL_ERROR(logLevel_, "Failed to load configuration file:", config_path); + throw std::runtime_error("Failed to load config file: " + config_path); + } + } + initialize(confManager); +} + +void ChronoSQLClientAdapter::initialize(const chronolog::ClientConfiguration& confManager) +{ + chronolog::ClientPortalServiceConf portalConf{confManager.PORTAL_CONF.PROTO_CONF, + confManager.PORTAL_CONF.IP, + confManager.PORTAL_CONF.PORT, + confManager.PORTAL_CONF.PROVIDER_ID}; + chronolog::ClientQueryServiceConf queryConf{confManager.QUERY_CONF.PROTO_CONF, + confManager.QUERY_CONF.IP, + confManager.QUERY_CONF.PORT, + confManager.QUERY_CONF.PROVIDER_ID}; + + CHRONOSQL_INFO(logLevel_, "Connecting to ChronoLog at", portalConf.IP, ":", portalConf.PORT); + client_ = std::make_unique(portalConf, queryConf); + if(int ret = client_->Connect(); ret != chronolog::CL_SUCCESS) + { + CHRONOSQL_ERROR(logLevel_, "Connection failed with error code:", ret); + client_->Disconnect(); + client_.reset(); + throw std::runtime_error("Failed to connect to ChronoLog with error code: " + std::to_string(ret)); + } + + std::map chronicle_attrs; + if(int ret = client_->CreateChronicle(chronicle_, chronicle_attrs, DEFAULT_FLAGS); + ret != chronolog::CL_SUCCESS && ret != chronolog::CL_ERR_CHRONICLE_EXISTS) + { + CHRONOSQL_ERROR(logLevel_, "Failed to create chronicle '", chronicle_, "' with error code:", ret); + throw std::runtime_error("Failed to create chronicle with error code: " + std::to_string(ret)); + } + CHRONOSQL_INFO(logLevel_, "Chronicle '", chronicle_, "' ready for operations"); +} + +ChronoSQLClientAdapter::~ChronoSQLClientAdapter() +{ + if(!client_) + { + return; + } + std::lock_guard lock(cacheMutex_); + for(const auto& [story, handle]: handleCache_) { client_->ReleaseStory(chronicle_, story); } + handleCache_.clear(); + client_->Disconnect(); +} + +chronolog::StoryHandle* ChronoSQLClientAdapter::getOrAcquireHandle(const std::string& story) +{ + std::lock_guard lock(cacheMutex_); + auto it = handleCache_.find(story); + if(it != handleCache_.end()) + { + return it->second; + } + std::map story_attrs; + auto [status, handle] = client_->AcquireStory(chronicle_, story, story_attrs, DEFAULT_FLAGS); + if(status != chronolog::CL_SUCCESS) + { + CHRONOSQL_ERROR(logLevel_, "Failed to acquire story handle for story='", story, "' code:", status); + throw std::runtime_error("Failed to acquire story handle for story: " + story + + ", error code: " + std::to_string(status)); + } + handleCache_[story] = handle; + return handle; +} + +void ChronoSQLClientAdapter::flushCachedHandle(const std::string& story) +{ + std::lock_guard lock(cacheMutex_); + auto it = handleCache_.find(story); + if(it != handleCache_.end()) + { + client_->ReleaseStory(chronicle_, story); + handleCache_.erase(it); + } +} + +std::uint64_t ChronoSQLClientAdapter::appendEvent(const std::string& story, const std::string& payload) +{ + chronolog::StoryHandle* handle = getOrAcquireHandle(story); + return handle->log_event(payload); +} + +std::vector +ChronoSQLClientAdapter::replayEvents(const std::string& story, std::uint64_t start_ts, std::uint64_t end_ts) +{ + flushCachedHandle(story); + + std::map story_attrs; + auto [status, handle] = client_->AcquireStory(chronicle_, story, story_attrs, DEFAULT_FLAGS); + if(status != chronolog::CL_SUCCESS) + { + CHRONOSQL_ERROR(logLevel_, "Failed to acquire story handle for story='", story, "' code:", status); + throw std::runtime_error("Failed to acquire story handle for story: " + story + + ", error code: " + std::to_string(status)); + } + + struct StoryHandleGuard + { + chronolog::Client* client; + const std::string& chronicle; + const std::string& key; + ~StoryHandleGuard() { client->ReleaseStory(chronicle, key); } + } guard{client_.get(), chronicle_, story}; + + std::vector events; + if(int ret = client_->ReplayStory(chronicle_, story, start_ts, end_ts, events); ret != chronolog::CL_SUCCESS) + { + CHRONOSQL_ERROR(logLevel_, "Failed to replay events for story='", story, "' code:", ret); + throw std::runtime_error("Failed to replay events for story: " + story + + ", error code: " + std::to_string(ret)); + } + std::vector out; + out.reserve(events.size()); + for(const auto& e: events) { out.push_back({e.time(), e.log_record()}); } + return out; +} + +void ChronoSQLClientAdapter::flush() +{ + std::lock_guard lock(cacheMutex_); + for(const auto& [story, handle]: handleCache_) { client_->ReleaseStory(chronicle_, story); } + handleCache_.clear(); +} + +} // namespace chronosql diff --git a/Plugins/chronosql/src/chronosql_client_adapter.h b/Plugins/chronosql/src/chronosql_client_adapter.h new file mode 100644 index 000000000..e8b80064b --- /dev/null +++ b/Plugins/chronosql/src/chronosql_client_adapter.h @@ -0,0 +1,71 @@ +#ifndef CHRONOSQL_CLIENT_ADAPTER_H_ +#define CHRONOSQL_CLIENT_ADAPTER_H_ + +#include +#include +#include +#include +#include +#include + +#include + +namespace chronolog +{ +class ClientConfiguration; +} + +#include "chronosql_logger.h" + +namespace chronosql +{ + +/** + * @brief Thin wrapper over the ChronoLog Client that speaks in + * timestamp/payload pairs and handles per-story handle caching. + * + * Mirrors ChronoKVS's adapter shape: writes go through cached handles; + * reads release the cached write handle for that story before replay. + */ +class ChronoSQLClientAdapter +{ +public: + struct EventPayload + { + std::uint64_t timestamp; + std::string payload; + }; + + ChronoSQLClientAdapter(const std::string& chronicle_name, LogLevel level); + + ChronoSQLClientAdapter(const std::string& chronicle_name, const std::string& config_path, LogLevel level); + + ~ChronoSQLClientAdapter(); + + /// Append an event to the named story. Returns the assigned timestamp. + std::uint64_t appendEvent(const std::string& story, const std::string& payload); + + /// Replay [start_ts, end_ts) from the named story. + std::vector replayEvents(const std::string& story, std::uint64_t start_ts, std::uint64_t end_ts); + + /// Release all cached write handles. Required before reads if the same + /// process holds active write handles for the target story. + void flush(); + + const std::string& chronicleName() const { return chronicle_; } + +private: + std::string chronicle_; + LogLevel logLevel_; + std::unique_ptr client_; + std::unordered_map handleCache_; + mutable std::mutex cacheMutex_; + + void initialize(const chronolog::ClientConfiguration& confManager); + chronolog::StoryHandle* getOrAcquireHandle(const std::string& story); + void flushCachedHandle(const std::string& story); +}; + +} // namespace chronosql + +#endif // CHRONOSQL_CLIENT_ADAPTER_H_ diff --git a/Plugins/chronosql/src/chronosql_mapper.cpp b/Plugins/chronosql/src/chronosql_mapper.cpp new file mode 100644 index 000000000..d8e2c8f1e --- /dev/null +++ b/Plugins/chronosql/src/chronosql_mapper.cpp @@ -0,0 +1,224 @@ +#include "chronosql_mapper.h" + +#include +#include +#include +#include +#include + +#include "chronosql_row_codec.h" + +namespace chronosql +{ + +namespace +{ +constexpr std::uint64_t MIN_TS = 1; +constexpr std::uint64_t MAX_TS = UINT64_MAX; + +bool isReservedName(const std::string& s) { return s.size() >= 2 && s[0] == '_' && s[1] == '_'; } +} // namespace + +ChronoSQLMapper::ChronoSQLMapper(LogLevel level) + : logLevel_(level) + , adapter_(std::make_unique(kDefaultChronicle, level)) +{} + +ChronoSQLMapper::ChronoSQLMapper(const std::string& config_path, LogLevel level) + : logLevel_(level) + , adapter_(std::make_unique(kDefaultChronicle, config_path, level)) +{} + +void ChronoSQLMapper::ensureMetadataLoaded() +{ + if(metadata_loaded_) + { + return; + } + auto events = adapter_->replayEvents(kMetadataStory, MIN_TS, MAX_TS); + std::sort(events.begin(), + events.end(), + [](const ChronoSQLClientAdapter::EventPayload& a, const ChronoSQLClientAdapter::EventPayload& b) + { return a.timestamp < b.timestamp; }); + for(const auto& e: events) { metadata_.apply(e.payload); } + metadata_loaded_ = true; +} + +void ChronoSQLMapper::appendMetadata(const std::string& payload) { adapter_->appendEvent(kMetadataStory, payload); } + +void ChronoSQLMapper::requireSchema(const std::string& table) const +{ + auto s = metadata_.get(table); + if(!s) + { + throw std::runtime_error("ChronoSQL: unknown table '" + table + "'"); + } +} + +void ChronoSQLMapper::createTable(const std::string& name, const std::vector& columns) +{ + if(name.empty()) + { + throw std::invalid_argument("ChronoSQL: table name cannot be empty"); + } + if(isReservedName(name)) + { + throw std::invalid_argument("ChronoSQL: table names cannot start with '__'"); + } + if(columns.empty()) + { + throw std::invalid_argument("ChronoSQL: table must declare at least one column"); + } + std::set seen; + for(const auto& c: columns) + { + if(c.name.empty()) + { + throw std::invalid_argument("ChronoSQL: column name cannot be empty"); + } + if(isReservedName(c.name)) + { + throw std::invalid_argument("ChronoSQL: column names cannot start with '__' (reserved)"); + } + if(!seen.insert(c.name).second) + { + throw std::invalid_argument("ChronoSQL: duplicate column '" + c.name + "'"); + } + } + + ensureMetadataLoaded(); + if(metadata_.has(name)) + { + throw std::runtime_error("ChronoSQL: table '" + name + "' already exists"); + } + + MetaCreateOp op{name, columns}; + appendMetadata(encodeCreateOp(op)); + metadata_.recordCreate(op); + CHRONOSQL_INFO(logLevel_, "Created table", name, "with", columns.size(), "columns"); +} + +void ChronoSQLMapper::dropTable(const std::string& name) +{ + ensureMetadataLoaded(); + if(!metadata_.has(name)) + { + throw std::runtime_error("ChronoSQL: cannot drop unknown table '" + name + "'"); + } + appendMetadata(encodeDropOp(MetaDropOp{name})); + metadata_.recordDrop(MetaDropOp{name}); + CHRONOSQL_INFO(logLevel_, "Dropped table", name); +} + +std::vector ChronoSQLMapper::listTables() +{ + ensureMetadataLoaded(); + return metadata_.listTables(); +} + +std::optional ChronoSQLMapper::getSchema(const std::string& name) +{ + ensureMetadataLoaded(); + return metadata_.get(name); +} + +void ChronoSQLMapper::validateColumnsAgainstSchema(const Schema& schema, + const std::map& row) const +{ + std::set declared; + for(const auto& c: schema.columns) { declared.insert(c.name); } + for(const auto& [k, v]: row) + { + (void)v; + if(declared.find(k) == declared.end()) + { + throw std::invalid_argument("ChronoSQL: unknown column '" + k + "' for table '" + schema.table + "'"); + } + } +} + +std::uint64_t ChronoSQLMapper::insert(const std::string& table, const std::map& row) +{ + ensureMetadataLoaded(); + auto schema = metadata_.get(table); + if(!schema) + { + throw std::runtime_error("ChronoSQL: cannot insert into unknown table '" + table + "'"); + } + validateColumnsAgainstSchema(*schema, row); + std::string payload = encodeRow(row); + return adapter_->appendEvent(table, payload); +} + +Row ChronoSQLMapper::eventToRow(std::uint64_t ts, const std::string& payload) const +{ + Row r; + r.timestamp = ts; + r.values = decodeRow(payload); + return r; +} + +std::vector ChronoSQLMapper::selectAll(const std::string& table) +{ + ensureMetadataLoaded(); + requireSchema(table); + auto events = adapter_->replayEvents(table, MIN_TS, MAX_TS); + std::vector out; + out.reserve(events.size()); + for(const auto& e: events) + { + try + { + out.push_back(eventToRow(e.timestamp, e.payload)); + } + catch(const std::exception& ex) + { + CHRONOSQL_WARNING(logLevel_, "Skipping malformed event in table", table, "ts=", e.timestamp); + } + } + return out; +} + +std::vector ChronoSQLMapper::selectRange(const std::string& table, std::uint64_t start_ts, std::uint64_t end_ts) +{ + if(start_ts >= end_ts) + { + throw std::invalid_argument("ChronoSQL: invalid range, start_ts must be < end_ts"); + } + ensureMetadataLoaded(); + requireSchema(table); + auto events = adapter_->replayEvents(table, start_ts, end_ts); + std::vector out; + out.reserve(events.size()); + for(const auto& e: events) + { + try + { + out.push_back(eventToRow(e.timestamp, e.payload)); + } + catch(const std::exception&) + { + CHRONOSQL_WARNING(logLevel_, "Skipping malformed event in table", table, "ts=", e.timestamp); + } + } + return out; +} + +std::vector +ChronoSQLMapper::selectByKey(const std::string& table, const std::string& column, const std::string& value) +{ + auto rows = selectAll(table); + rows.erase(std::remove_if(rows.begin(), + rows.end(), + [&](const Row& r) + { + auto it = r.values.find(column); + return it == r.values.end() || it->second != value; + }), + rows.end()); + return rows; +} + +void ChronoSQLMapper::flush() { adapter_->flush(); } + +} // namespace chronosql diff --git a/Plugins/chronosql/src/chronosql_mapper.h b/Plugins/chronosql/src/chronosql_mapper.h new file mode 100644 index 000000000..d05dc087c --- /dev/null +++ b/Plugins/chronosql/src/chronosql_mapper.h @@ -0,0 +1,64 @@ +#ifndef CHRONOSQL_MAPPER_H_ +#define CHRONOSQL_MAPPER_H_ + +#include +#include +#include +#include +#include +#include + +#include "chronosql_client_adapter.h" +#include "chronosql_metadata.h" +#include "chronosql_logger.h" +#include "chronosql_types.h" + +namespace chronosql +{ + +/** + * @brief Translates SQL-shaped operations (table-level CRUD) into ChronoLog + * events on top of the ChronoSQLClientAdapter. + * + * Owns the in-memory MetadataState and is responsible for keeping it in sync + * with the metadata story. + */ +class ChronoSQLMapper +{ +public: + explicit ChronoSQLMapper(LogLevel level); + explicit ChronoSQLMapper(const std::string& config_path, LogLevel level); + ~ChronoSQLMapper() = default; + + void createTable(const std::string& name, const std::vector& columns); + void dropTable(const std::string& name); + std::vector listTables(); + std::optional getSchema(const std::string& name); + + std::uint64_t insert(const std::string& table, const std::map& row); + + std::vector selectByKey(const std::string& table, const std::string& column, const std::string& value); + std::vector selectRange(const std::string& table, std::uint64_t start_ts, std::uint64_t end_ts); + std::vector selectAll(const std::string& table); + + void flush(); + +private: + LogLevel logLevel_; + std::unique_ptr adapter_; + MetadataState metadata_; + bool metadata_loaded_{false}; + + static constexpr const char* kMetadataStory = "__chronosql_metadata"; + static constexpr const char* kDefaultChronicle = "ChronoSQLChronicle"; + + void ensureMetadataLoaded(); + void appendMetadata(const std::string& payload); + void requireSchema(const std::string& table) const; + Row eventToRow(std::uint64_t ts, const std::string& payload) const; + void validateColumnsAgainstSchema(const Schema& schema, const std::map& row) const; +}; + +} // namespace chronosql + +#endif // CHRONOSQL_MAPPER_H_ diff --git a/Plugins/chronosql/src/chronosql_metadata.cpp b/Plugins/chronosql/src/chronosql_metadata.cpp new file mode 100644 index 000000000..8d8f9394a --- /dev/null +++ b/Plugins/chronosql/src/chronosql_metadata.cpp @@ -0,0 +1,178 @@ +#include "chronosql_metadata.h" + +#include +#include + +#include + +namespace chronosql +{ +namespace +{ + +json_object* columnsToJson(const std::vector& cols) +{ + json_object* arr = json_object_new_array(); + for(const auto& c: cols) + { + json_object* obj = json_object_new_object(); + json_object_object_add(obj, "name", json_object_new_string(c.name.c_str())); + json_object_object_add(obj, "type", json_object_new_string(columnTypeToString(c.type))); + json_object_array_add(arr, obj); + } + return arr; +} + +std::string toJsonString(json_object* obj) +{ + const char* s = json_object_to_json_string_ext(obj, JSON_C_TO_STRING_PLAIN); + std::string out(s ? s : ""); + json_object_put(obj); + return out; +} + +} // namespace + +std::string encodeCreateOp(const MetaCreateOp& op) +{ + json_object* root = json_object_new_object(); + json_object_object_add(root, "op", json_object_new_string("create_table")); + json_object_object_add(root, "name", json_object_new_string(op.table.c_str())); + json_object_object_add(root, "columns", columnsToJson(op.columns)); + return toJsonString(root); +} + +std::string encodeDropOp(const MetaDropOp& op) +{ + json_object* root = json_object_new_object(); + json_object_object_add(root, "op", json_object_new_string("drop_table")); + json_object_object_add(root, "name", json_object_new_string(op.table.c_str())); + return toJsonString(root); +} + +void MetadataState::apply(const std::string& payload) +{ + json_tokener* tok = json_tokener_new(); + json_object* root = json_tokener_parse_ex(tok, payload.c_str(), static_cast(payload.size())); + json_tokener_error err = json_tokener_get_error(tok); + json_tokener_free(tok); + if(!root || err != json_tokener_success || !json_object_is_type(root, json_type_object)) + { + if(root) + { + json_object_put(root); + } + // Skip malformed metadata events rather than aborting replay. + return; + } + + json_object* op_obj = nullptr; + json_object* name_obj = nullptr; + if(!json_object_object_get_ex(root, "op", &op_obj) || !json_object_object_get_ex(root, "name", &name_obj)) + { + json_object_put(root); + return; + } + std::string op = json_object_get_string(op_obj); + std::string table = json_object_get_string(name_obj); + + if(op == "create_table") + { + json_object* cols_obj = nullptr; + if(!json_object_object_get_ex(root, "columns", &cols_obj) || !json_object_is_type(cols_obj, json_type_array)) + { + json_object_put(root); + return; + } + MetaCreateOp create_op; + create_op.table = table; + std::size_t n = json_object_array_length(cols_obj); + for(std::size_t i = 0; i < n; ++i) + { + json_object* col_obj = json_object_array_get_idx(cols_obj, static_cast(i)); + if(!json_object_is_type(col_obj, json_type_object)) + { + continue; + } + json_object* cname_obj = nullptr; + json_object* ctype_obj = nullptr; + if(!json_object_object_get_ex(col_obj, "name", &cname_obj)) + { + continue; + } + Column c; + c.name = json_object_get_string(cname_obj); + if(json_object_object_get_ex(col_obj, "type", &ctype_obj)) + { + auto resolved = columnTypeFromString(json_object_get_string(ctype_obj)); + if(resolved) + { + c.type = *resolved; + } + } + create_op.columns.push_back(std::move(c)); + } + recordCreate(create_op); + } + else if(op == "drop_table") + { + recordDrop(MetaDropOp{table}); + } + json_object_put(root); +} + +bool MetadataState::has(const std::string& table) const { return schemas_.find(table) != schemas_.end(); } + +std::optional MetadataState::get(const std::string& table) const +{ + auto it = schemas_.find(table); + if(it == schemas_.end()) + { + return std::nullopt; + } + return it->second; +} + +std::vector MetadataState::listTables() const +{ + std::vector out; + out.reserve(order_.size()); + for(const auto& t: order_) + { + if(schemas_.find(t) != schemas_.end()) + { + out.push_back(t); + } + } + return out; +} + +void MetadataState::clear() +{ + order_.clear(); + schemas_.clear(); +} + +void MetadataState::recordCreate(const MetaCreateOp& op) +{ + if(schemas_.find(op.table) != schemas_.end()) + { + return; // already exists; replay idempotency. + } + Schema s; + s.table = op.table; + s.columns = op.columns; + schemas_.emplace(op.table, std::move(s)); + if(std::find(order_.begin(), order_.end(), op.table) == order_.end()) + { + order_.push_back(op.table); + } +} + +void MetadataState::recordDrop(const MetaDropOp& op) +{ + schemas_.erase(op.table); + // Keep order_ entry; it filters via schemas_ in listTables(). +} + +} // namespace chronosql diff --git a/Plugins/chronosql/src/chronosql_metadata.h b/Plugins/chronosql/src/chronosql_metadata.h new file mode 100644 index 000000000..e10df665b --- /dev/null +++ b/Plugins/chronosql/src/chronosql_metadata.h @@ -0,0 +1,62 @@ +#ifndef CHRONOSQL_METADATA_H_ +#define CHRONOSQL_METADATA_H_ + +#include +#include +#include +#include + +#include "chronosql_types.h" + +namespace chronosql +{ + +/** + * @brief Encode/decode metadata operations stored in the reserved + * `__chronosql_metadata` story. + * + * Each metadata event is a JSON object describing exactly one schema + * operation (CREATE TABLE, DROP TABLE). Replaying the events in timestamp + * order reconstructs the current schema/table-list state. + */ +struct MetaCreateOp +{ + std::string table; + std::vector columns; +}; + +struct MetaDropOp +{ + std::string table; +}; + +std::string encodeCreateOp(const MetaCreateOp& op); +std::string encodeDropOp(const MetaDropOp& op); + +/** + * @brief In-memory metadata state: known tables and their schemas, in CREATE + * order. dropTable() removes a table from the live set. + */ +class MetadataState +{ +public: + /// Apply a single metadata payload (encoded JSON). Unknown ops are ignored + /// to keep forward compatibility cheap. + void apply(const std::string& payload); + + bool has(const std::string& table) const; + std::optional get(const std::string& table) const; + std::vector listTables() const; + void clear(); + + void recordCreate(const MetaCreateOp& op); + void recordDrop(const MetaDropOp& op); + +private: + std::vector order_; // CREATE-time insertion order + std::map schemas_; // live tables +}; + +} // namespace chronosql + +#endif // CHRONOSQL_METADATA_H_ diff --git a/Plugins/chronosql/src/chronosql_row_codec.cpp b/Plugins/chronosql/src/chronosql_row_codec.cpp new file mode 100644 index 000000000..42c4df480 --- /dev/null +++ b/Plugins/chronosql/src/chronosql_row_codec.cpp @@ -0,0 +1,61 @@ +#include "chronosql_row_codec.h" + +#include +#include + +#include + +namespace chronosql +{ + +std::string encodeRow(const std::map& row) +{ + json_object* obj = json_object_new_object(); + for(const auto& [k, v]: row) { json_object_object_add(obj, k.c_str(), json_object_new_string(v.c_str())); } + const char* out = json_object_to_json_string_ext(obj, JSON_C_TO_STRING_PLAIN); + std::string result(out ? out : "{}"); + json_object_put(obj); + return result; +} + +std::map decodeRow(const std::string& payload) +{ + json_tokener* tok = json_tokener_new(); + json_object* root = json_tokener_parse_ex(tok, payload.c_str(), static_cast(payload.size())); + json_tokener_error err = json_tokener_get_error(tok); + json_tokener_free(tok); + + if(!root || err != json_tokener_success) + { + if(root) + { + json_object_put(root); + } + throw std::runtime_error("ChronoSQL: failed to parse row JSON payload"); + } + + if(!json_object_is_type(root, json_type_object)) + { + json_object_put(root); + throw std::runtime_error("ChronoSQL: row payload must be a JSON object"); + } + + std::map out; + json_object_object_foreach(root, key, val) + { + if(json_object_is_type(val, json_type_string)) + { + out.emplace(key, json_object_get_string(val)); + } + else + { + // Non-string values are stringified for uniform handling. + const char* s = json_object_to_json_string_ext(val, JSON_C_TO_STRING_PLAIN); + out.emplace(key, s ? s : ""); + } + } + json_object_put(root); + return out; +} + +} // namespace chronosql diff --git a/Plugins/chronosql/src/chronosql_row_codec.h b/Plugins/chronosql/src/chronosql_row_codec.h new file mode 100644 index 000000000..14c18bf2c --- /dev/null +++ b/Plugins/chronosql/src/chronosql_row_codec.h @@ -0,0 +1,29 @@ +#ifndef CHRONOSQL_ROW_CODEC_H_ +#define CHRONOSQL_ROW_CODEC_H_ + +#include +#include + +namespace chronosql +{ + +/** + * @brief Encode a row's column-value map as a compact JSON object. + * + * Values are always serialized as JSON strings; ChronoSQL keeps stored values + * as strings regardless of the schema's declared column type. NULL columns + * (absent from the map) are omitted from the JSON output. + */ +std::string encodeRow(const std::map& row); + +/** + * @brief Decode a JSON object emitted by encodeRow back into a column-value + * map. Non-string JSON values are stringified. + * + * @throws std::runtime_error on invalid JSON or non-object root. + */ +std::map decodeRow(const std::string& payload); + +} // namespace chronosql + +#endif // CHRONOSQL_ROW_CODEC_H_ diff --git a/Plugins/chronosql/src/chronosql_sql_parser.cpp b/Plugins/chronosql/src/chronosql_sql_parser.cpp new file mode 100644 index 000000000..c15a5ba67 --- /dev/null +++ b/Plugins/chronosql/src/chronosql_sql_parser.cpp @@ -0,0 +1,521 @@ +#include "chronosql_sql_parser.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace chronosql +{ +namespace +{ + +enum class Tok +{ + Ident, + StringLit, + Integer, + Decimal, + LParen, + RParen, + Comma, + Semicolon, + Equals, + Star, + End +}; + +struct Token +{ + Tok kind; + std::string text; // identifier text or numeric digits (lowercased for identifiers) + std::string original; // original-case text for error messages + std::size_t pos{0}; +}; + +bool iequals(const std::string& a, const char* b) +{ + std::size_t n = std::char_traits::length(b); + if(a.size() != n) + { + return false; + } + for(std::size_t i = 0; i < n; ++i) + { + if(std::tolower(static_cast(a[i])) != std::tolower(static_cast(b[i]))) + { + return false; + } + } + return true; +} + +class Tokenizer +{ +public: + explicit Tokenizer(const std::string& src) + : src_(src) + {} + + std::vector tokenize() + { + std::vector out; + while(pos_ < src_.size()) + { + char c = src_[pos_]; + if(std::isspace(static_cast(c))) + { + ++pos_; + continue; + } + if(c == ',') + { + out.push_back({Tok::Comma, ",", ",", pos_}); + ++pos_; + continue; + } + if(c == '(') + { + out.push_back({Tok::LParen, "(", "(", pos_}); + ++pos_; + continue; + } + if(c == ')') + { + out.push_back({Tok::RParen, ")", ")", pos_}); + ++pos_; + continue; + } + if(c == ';') + { + out.push_back({Tok::Semicolon, ";", ";", pos_}); + ++pos_; + continue; + } + if(c == '=') + { + out.push_back({Tok::Equals, "=", "=", pos_}); + ++pos_; + continue; + } + if(c == '*') + { + out.push_back({Tok::Star, "*", "*", pos_}); + ++pos_; + continue; + } + if(c == '\'') + { + out.push_back(readString()); + continue; + } + if(std::isdigit(static_cast(c)) || c == '-' || c == '+') + { + // Disambiguate +/-: only treat as numeric prefix if next char is a digit. + if((c == '-' || c == '+') && + (pos_ + 1 >= src_.size() || !std::isdigit(static_cast(src_[pos_ + 1])))) + { + error("unexpected character", pos_); + } + out.push_back(readNumber()); + continue; + } + if(std::isalpha(static_cast(c)) || c == '_') + { + out.push_back(readIdent()); + continue; + } + error("unexpected character", pos_); + } + out.push_back({Tok::End, "", "", pos_}); + return out; + } + +private: + const std::string& src_; + std::size_t pos_{0}; + + [[noreturn]] void error(const std::string& msg, std::size_t at) + { + throw std::invalid_argument("ChronoSQL parse error at offset " + std::to_string(at) + ": " + msg); + } + + Token readString() + { + std::size_t start = pos_; + ++pos_; // consume opening quote + std::string out; + while(pos_ < src_.size()) + { + char c = src_[pos_]; + if(c == '\'') + { + if(pos_ + 1 < src_.size() && src_[pos_ + 1] == '\'') + { + out += '\''; + pos_ += 2; + continue; + } + ++pos_; + return {Tok::StringLit, out, out, start}; + } + out += c; + ++pos_; + } + error("unterminated string literal", start); + } + + Token readNumber() + { + std::size_t start = pos_; + if(src_[pos_] == '-' || src_[pos_] == '+') + { + ++pos_; + } + bool seen_dot = false; + while(pos_ < src_.size()) + { + char c = src_[pos_]; + if(std::isdigit(static_cast(c))) + { + ++pos_; + continue; + } + if(c == '.' && !seen_dot) + { + seen_dot = true; + ++pos_; + continue; + } + break; + } + std::string lex = src_.substr(start, pos_ - start); + return {seen_dot ? Tok::Decimal : Tok::Integer, lex, lex, start}; + } + + Token readIdent() + { + std::size_t start = pos_; + while(pos_ < src_.size()) + { + char c = src_[pos_]; + if(std::isalnum(static_cast(c)) || c == '_') + { + ++pos_; + continue; + } + break; + } + std::string original = src_.substr(start, pos_ - start); + std::string lower = original; + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char ch) { return std::tolower(ch); }); + return {Tok::Ident, lower, original, start}; + } +}; + +class Parser +{ +public: + explicit Parser(std::vector tokens) + : toks_(std::move(tokens)) + {} + + ParsedStatement parse() + { + const Token& kw = peek(); + if(kw.kind != Tok::Ident) + { + fail("expected SQL keyword", kw); + } + ParsedStatement out; + if(iequals(kw.text, "create")) + { + out = parseCreate(); + } + else if(iequals(kw.text, "drop")) + { + out = parseDrop(); + } + else if(iequals(kw.text, "insert")) + { + out = parseInsert(); + } + else if(iequals(kw.text, "select")) + { + out = parseSelect(); + } + else + { + fail("unsupported statement '" + kw.original + "'", kw); + } + // Optional trailing semicolon. + if(peek().kind == Tok::Semicolon) + { + ++idx_; + } + if(peek().kind != Tok::End) + { + fail("trailing tokens after statement", peek()); + } + return out; + } + +private: + std::vector toks_; + std::size_t idx_{0}; + + const Token& peek() const { return toks_[idx_]; } + + [[noreturn]] void fail(const std::string& msg, const Token& t) + { + throw std::invalid_argument("ChronoSQL parse error at offset " + std::to_string(t.pos) + ": " + msg); + } + + void expectKeyword(const char* kw) + { + const Token& t = peek(); + if(t.kind != Tok::Ident || !iequals(t.text, kw)) + { + fail(std::string("expected '") + kw + "'", t); + } + ++idx_; + } + + Token expectIdent() + { + const Token& t = peek(); + if(t.kind != Tok::Ident) + { + fail("expected identifier", t); + } + ++idx_; + return t; + } + + void expect(Tok kind, const char* what) + { + const Token& t = peek(); + if(t.kind != kind) + { + fail(std::string("expected '") + what + "'", t); + } + ++idx_; + } + + ParsedCreateTable parseCreate() + { + ++idx_; // consume CREATE + expectKeyword("table"); + ParsedCreateTable out; + out.table = expectIdent().original; + expect(Tok::LParen, "("); + while(true) + { + Column col; + col.name = expectIdent().original; + // optional type identifier + const Token& maybeType = peek(); + if(maybeType.kind == Tok::Ident) + { + std::string t = maybeType.original; + ++idx_; + if(peek().kind == Tok::LParen) + { + // accept VARCHAR(N) and similar; ignore length. + ++idx_; + if(peek().kind != Tok::Integer) + { + fail("expected integer length", peek()); + } + ++idx_; + expect(Tok::RParen, ")"); + } + auto resolved = columnTypeFromString(t); + if(!resolved) + { + fail("unknown column type '" + t + "'", maybeType); + } + col.type = *resolved; + } + out.columns.push_back(std::move(col)); + if(peek().kind == Tok::Comma) + { + ++idx_; + continue; + } + break; + } + expect(Tok::RParen, ")"); + return out; + } + + ParsedDropTable parseDrop() + { + ++idx_; // DROP + expectKeyword("table"); + ParsedDropTable out; + out.table = expectIdent().original; + return out; + } + + ParsedInsert parseInsert() + { + ++idx_; // INSERT + expectKeyword("into"); + ParsedInsert out; + out.table = expectIdent().original; + if(peek().kind == Tok::LParen) + { + ++idx_; + while(true) + { + out.columns.push_back(expectIdent().original); + if(peek().kind == Tok::Comma) + { + ++idx_; + continue; + } + break; + } + expect(Tok::RParen, ")"); + } + expectKeyword("values"); + expect(Tok::LParen, "("); + while(true) + { + const Token& v = peek(); + if(v.kind == Tok::Ident && iequals(v.text, "null")) + { + ++idx_; + out.values.push_back(""); + out.is_null.push_back(true); + } + else if(v.kind == Tok::StringLit || v.kind == Tok::Integer || v.kind == Tok::Decimal) + { + ++idx_; + out.values.push_back(v.text); + out.is_null.push_back(false); + } + else + { + fail("expected value literal", v); + } + if(peek().kind == Tok::Comma) + { + ++idx_; + continue; + } + break; + } + expect(Tok::RParen, ")"); + if(!out.columns.empty() && out.columns.size() != out.values.size()) + { + fail("INSERT column count does not match value count", peek()); + } + return out; + } + + ParsedSelect parseSelect() + { + ++idx_; // SELECT + ParsedSelect out; + if(peek().kind == Tok::Star) + { + ++idx_; + } + else + { + while(true) + { + out.projection.push_back(expectIdent().original); + if(peek().kind == Tok::Comma) + { + ++idx_; + continue; + } + break; + } + } + expectKeyword("from"); + out.table = expectIdent().original; + if(peek().kind == Tok::Ident && iequals(peek().text, "where")) + { + ++idx_; + const Token& lhs = peek(); + if(lhs.kind != Tok::Ident) + { + fail("expected column in WHERE", lhs); + } + // __ts BETWEEN n AND n + if(lhs.text == "__ts" && idx_ + 1 < toks_.size() && toks_[idx_ + 1].kind == Tok::Ident && + iequals(toks_[idx_ + 1].text, "between")) + { + ++idx_; // __ts + ++idx_; // BETWEEN + const Token& lo = peek(); + if(lo.kind != Tok::Integer) + { + fail("expected integer for BETWEEN lower bound", lo); + } + ++idx_; + expectKeyword("and"); + const Token& hi = peek(); + if(hi.kind != Tok::Integer) + { + fail("expected integer for BETWEEN upper bound", hi); + } + ++idx_; + WhereTsBetween w; + try + { + w.lo = std::stoull(lo.text); + w.hi = std::stoull(hi.text); + } + catch(const std::exception&) + { + fail("BETWEEN bounds out of range", lo); + } + out.where = w; + } + else + { + WhereEq w; + w.column = expectIdent().original; + expect(Tok::Equals, "="); + const Token& v = peek(); + if(v.kind == Tok::Ident && iequals(v.text, "null")) + { + ++idx_; + w.is_null = true; + } + else if(v.kind == Tok::StringLit || v.kind == Tok::Integer || v.kind == Tok::Decimal) + { + ++idx_; + w.value = v.text; + } + else + { + fail("expected literal value in WHERE", v); + } + out.where = w; + } + } + return out; + } +}; + +} // namespace + +ParsedStatement parseStatement(const std::string& sql) +{ + Tokenizer tk(sql); + auto tokens = tk.tokenize(); + Parser p(std::move(tokens)); + return p.parse(); +} + +} // namespace chronosql diff --git a/Plugins/chronosql/src/chronosql_sql_parser.h b/Plugins/chronosql/src/chronosql_sql_parser.h new file mode 100644 index 000000000..045347704 --- /dev/null +++ b/Plugins/chronosql/src/chronosql_sql_parser.h @@ -0,0 +1,70 @@ +#ifndef CHRONOSQL_SQL_PARSER_H_ +#define CHRONOSQL_SQL_PARSER_H_ + +#include +#include +#include +#include + +#include "chronosql_types.h" + +namespace chronosql +{ + +/** + * @brief Parsed AST for the SQL subset accepted by ChronoSQL::execute(). + * + * The grammar is intentionally small and targeted at the YCSB workload shape. + * See ChronoSQL::execute() in chronosql.h for the supported productions. + */ + +struct ParsedCreateTable +{ + std::string table; + std::vector columns; +}; + +struct ParsedDropTable +{ + std::string table; +}; + +struct ParsedInsert +{ + std::string table; + std::vector columns; // empty means "use schema order" + std::vector values; // string-form of each literal (NULL → "") + std::vector is_null; // parallel to values +}; + +struct WhereEq +{ + std::string column; + std::string value; + bool is_null{false}; +}; + +struct WhereTsBetween +{ + std::uint64_t lo{0}; + std::uint64_t hi{0}; // inclusive +}; + +struct ParsedSelect +{ + std::string table; + std::vector projection; // empty means SELECT * + std::variant where; +}; + +using ParsedStatement = std::variant; + +/** + * @brief Parse a single SQL statement. + * @throws std::invalid_argument with a descriptive message on parse failure. + */ +ParsedStatement parseStatement(const std::string& sql); + +} // namespace chronosql + +#endif // CHRONOSQL_SQL_PARSER_H_ diff --git a/test/integration/CMakeLists.txt b/test/integration/CMakeLists.txt index 5cae29145..ba272bf00 100644 --- a/test/integration/CMakeLists.txt +++ b/test/integration/CMakeLists.txt @@ -3,4 +3,5 @@ cmake_minimum_required(VERSION 3.25) add_subdirectory(client) add_subdirectory(keeper-grapher) add_subdirectory(chronokvs) +add_subdirectory(chronosql) add_subdirectory(package-discovery) \ No newline at end of file diff --git a/test/integration/chronosql/CMakeLists.txt b/test/integration/chronosql/CMakeLists.txt new file mode 100644 index 000000000..3311eb3f2 --- /dev/null +++ b/test/integration/chronosql/CMakeLists.txt @@ -0,0 +1,39 @@ +cmake_minimum_required(VERSION 3.25) + +find_package(Threads REQUIRED) + +add_executable(chronosql_integration_test + chronosql_integration_test.cpp +) + +target_include_directories(chronosql_integration_test PRIVATE + ${CMAKE_SOURCE_DIR}/Plugins/chronosql/include +) + +target_link_libraries(chronosql_integration_test + PRIVATE + chronosql + Threads::Threads +) + +target_compile_features(chronosql_integration_test PRIVATE cxx_std_17) + +set_target_properties(chronosql_integration_test PROPERTIES + OUTPUT_NAME chronolog-test-chronosql-integration + BUILD_WITH_INSTALL_RPATH TRUE + SKIP_BUILD_RPATH TRUE +) + +add_test(NAME Integration_ChronoSQL_PluginIntegration + COMMAND chronosql_integration_test + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} +) + +set_tests_properties(Integration_ChronoSQL_PluginIntegration PROPERTIES + MANUAL TRUE + TIMEOUT 300 +) + +if(CHRONOLOG_INSTALL_TESTS) + chronolog_install_target(chronosql_integration_test DESTINATION tests) +endif() diff --git a/test/integration/chronosql/chronosql_integration_test.cpp b/test/integration/chronosql/chronosql_integration_test.cpp new file mode 100644 index 000000000..792260664 --- /dev/null +++ b/test/integration/chronosql/chronosql_integration_test.cpp @@ -0,0 +1,100 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "chronosql.h" + +// ChronoSQL integration test (MANUAL). +// +// Exercises the full plugin against a running ChronoLog deployment using a +// small YCSB-shaped workload: CREATE TABLE, INSERT (programmatic + SQL), +// SELECT * (full scan), SELECT WHERE col=, SELECT WHERE __ts BETWEEN. +// +// Does not auto-run in CI: the test is registered MANUAL TRUE in CMake. + +namespace +{ + +bool expect_eq(const char* what, std::size_t actual, std::size_t expected) +{ + if(actual != expected) + { + std::cerr << "[FAIL] " << what << ": expected " << expected << " got " << actual << "\n"; + return false; + } + std::cout << "[OK] " << what << ": " << actual << "\n"; + return true; +} + +} // namespace + +int main(int argc, char** argv) +{ + std::string conf_path; + for(int i = 1; i < argc; ++i) + { + std::string a = argv[i]; + if(a == "-c" || a == "--config") + { + if(i + 1 < argc) + { + conf_path = argv[++i]; + } + } + } + + chronosql::ChronoSQL db = conf_path.empty() ? chronosql::ChronoSQL() : chronosql::ChronoSQL(conf_path); + + const std::string table = "ycsb_users"; + if(!db.getSchema(table).has_value()) + { + db.execute("CREATE TABLE " + table + " (id INT, name STRING, region STRING)"); + } + + constexpr int N = 100; + std::vector ts; + ts.reserve(N); + + auto t0 = std::chrono::steady_clock::now(); + for(int i = 0; i < N; ++i) + { + std::string region = (i % 2 == 0) ? "us-east" : "us-west"; + std::string sql = "INSERT INTO " + table + " (id, name, region) VALUES (" + std::to_string(i) + ", 'user" + + std::to_string(i) + "', '" + region + "')"; + auto r = db.execute(sql); + if(!r.last_insert_timestamp.has_value()) + { + std::cerr << "INSERT did not return a timestamp\n"; + return EXIT_FAILURE; + } + ts.push_back(*r.last_insert_timestamp); + } + db.flush(); + std::this_thread::sleep_for(std::chrono::seconds(2)); + + bool ok = true; + auto all = db.execute("SELECT * FROM " + table); + ok &= expect_eq("SELECT * row count", all.rows.size(), N); + + auto east = db.execute("SELECT id, region FROM " + table + " WHERE region = 'us-east'"); + ok &= expect_eq("SELECT WHERE region='us-east'", east.rows.size(), N / 2); + + auto by_id = db.execute("SELECT * FROM " + table + " WHERE id = 42"); + ok &= expect_eq("SELECT WHERE id=42", by_id.rows.size(), 1u); + + if(N >= 10) + { + auto range = db.execute("SELECT * FROM " + table + " WHERE __ts BETWEEN " + std::to_string(ts[10]) + " AND " + + std::to_string(ts[19])); + ok &= expect_eq("SELECT WHERE __ts BETWEEN ts[10] AND ts[19] (inclusive)", range.rows.size(), 10u); + } + + auto t1 = std::chrono::steady_clock::now(); + std::cout << "Total time: " << std::chrono::duration_cast(t1 - t0).count() << " ms\n"; + + return ok ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index 4401ba4af..fabe2a2d4 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -10,3 +10,4 @@ add_subdirectory(chrono-common) add_subdirectory(chrono-store) add_subdirectory(chrono-player) add_subdirectory(chronokvs) +add_subdirectory(chronosql) diff --git a/test/unit/chronosql/CMakeLists.txt b/test/unit/chronosql/CMakeLists.txt new file mode 100644 index 000000000..adb263986 --- /dev/null +++ b/test/unit/chronosql/CMakeLists.txt @@ -0,0 +1,54 @@ +cmake_minimum_required(VERSION 3.25) + +find_package(GTest REQUIRED) +include(GoogleTest) + +#------------------------------------------------------------------------------ +# ChronoSQL parser/codec/metadata unit tests +# These do not require a live ChronoLog deployment. +#------------------------------------------------------------------------------ +add_executable(chronosql_unit_test + chronosql_sql_parser_test.cpp + chronosql_row_codec_test.cpp + chronosql_metadata_test.cpp +) + +target_include_directories(chronosql_unit_test PRIVATE + ${CMAKE_SOURCE_DIR}/Plugins/chronosql/include + ${CMAKE_SOURCE_DIR}/Plugins/chronosql/src +) + +target_link_libraries(chronosql_unit_test PRIVATE + chronosql + GTest::gtest_main +) + +set_target_properties(chronosql_unit_test PROPERTIES OUTPUT_NAME chronolog-test-chronosql-unit) +gtest_discover_tests(chronosql_unit_test TEST_PREFIX "Unit_ChronoSQL_") +if(CHRONOLOG_INSTALL_TESTS) + chronolog_install_target(chronosql_unit_test DESTINATION tests) +endif() + +#------------------------------------------------------------------------------ +# ChronoSQL configuration constructor unit test +# Verifies the config_path constructor throws on missing/invalid files before +# attempting to connect. +#------------------------------------------------------------------------------ +add_executable(chronosql_config_test + chronosql_config_test.cpp +) + +target_include_directories(chronosql_config_test PRIVATE + ${CMAKE_SOURCE_DIR}/Plugins/chronosql/include +) + +target_link_libraries(chronosql_config_test PRIVATE + chronosql + GTest::gtest_main +) + +set_target_properties(chronosql_config_test PROPERTIES OUTPUT_NAME chronolog-test-chronosql-config) +gtest_discover_tests(chronosql_config_test TEST_PREFIX "Unit_ChronoSQL_") +if(CHRONOLOG_INSTALL_TESTS) + chronolog_install_target(chronosql_config_test DESTINATION tests) +endif() diff --git a/test/unit/chronosql/chronosql_config_test.cpp b/test/unit/chronosql/chronosql_config_test.cpp new file mode 100644 index 000000000..96fbf59dd --- /dev/null +++ b/test/unit/chronosql/chronosql_config_test.cpp @@ -0,0 +1,28 @@ +#include +#include +#include +#include + +#include + +// Smoke tests for the config-file constructor. These run without a live +// ChronoLog deployment: they only exercise the load path that runs before +// any RPC connection attempt. + +TEST(ChronoSQLConfig, ThrowsOnNonexistentConfigFile) +{ + EXPECT_THROW(chronosql::ChronoSQL{"/this/path/should/not/exist/chronosql.json"}, std::runtime_error); +} + +TEST(ChronoSQLConfig, ThrowsOnConfigFileMissingChronoClientSection) +{ + const std::string path = "/tmp/chronosql_no_section_config.json"; + { + std::FILE* f = std::fopen(path.c_str(), "w"); + ASSERT_NE(f, nullptr); + std::fputs("{}", f); + std::fclose(f); + } + EXPECT_THROW(chronosql::ChronoSQL{path}, std::runtime_error); + std::remove(path.c_str()); +} diff --git a/test/unit/chronosql/chronosql_metadata_test.cpp b/test/unit/chronosql/chronosql_metadata_test.cpp new file mode 100644 index 000000000..d32a0bb8e --- /dev/null +++ b/test/unit/chronosql/chronosql_metadata_test.cpp @@ -0,0 +1,75 @@ +#include + +#include "chronosql_metadata.h" + +using namespace chronosql; + +namespace +{ +MetaCreateOp createOp(const std::string& table) +{ + MetaCreateOp op; + op.table = table; + op.columns.push_back({"id", ColumnType::INT}); + op.columns.push_back({"name", ColumnType::STRING}); + return op; +} +} // namespace + +TEST(ChronoSQLMetadata, RecordCreateThenList) +{ + MetadataState state; + state.recordCreate(createOp("users")); + state.recordCreate(createOp("orders")); + auto tables = state.listTables(); + ASSERT_EQ(tables.size(), 2u); + EXPECT_EQ(tables[0], "users"); + EXPECT_EQ(tables[1], "orders"); +} + +TEST(ChronoSQLMetadata, RecordDropRemovesTable) +{ + MetadataState state; + state.recordCreate(createOp("users")); + state.recordDrop({"users"}); + EXPECT_FALSE(state.has("users")); + EXPECT_TRUE(state.listTables().empty()); +} + +TEST(ChronoSQLMetadata, ApplyEncodedCreateRoundtrip) +{ + MetadataState state; + auto payload = encodeCreateOp(createOp("users")); + state.apply(payload); + ASSERT_TRUE(state.has("users")); + auto schema = state.get("users"); + ASSERT_TRUE(schema.has_value()); + EXPECT_EQ(schema->columns.size(), 2u); + EXPECT_EQ(schema->columns[0].name, "id"); + EXPECT_EQ(schema->columns[0].type, ColumnType::INT); +} + +TEST(ChronoSQLMetadata, ApplyEncodedDropRoundtrip) +{ + MetadataState state; + state.apply(encodeCreateOp(createOp("users"))); + state.apply(encodeDropOp({"users"})); + EXPECT_FALSE(state.has("users")); +} + +TEST(ChronoSQLMetadata, ApplyMalformedIsTolerated) +{ + MetadataState state; + state.apply("not json"); + state.apply("{}"); + state.apply(R"({"op":"unknown_op","name":"x"})"); + EXPECT_TRUE(state.listTables().empty()); +} + +TEST(ChronoSQLMetadata, RecordCreateIdempotentOnReplay) +{ + MetadataState state; + state.recordCreate(createOp("users")); + state.recordCreate(createOp("users")); // duplicate replay + EXPECT_EQ(state.listTables().size(), 1u); +} diff --git a/test/unit/chronosql/chronosql_row_codec_test.cpp b/test/unit/chronosql/chronosql_row_codec_test.cpp new file mode 100644 index 000000000..6bd5c2011 --- /dev/null +++ b/test/unit/chronosql/chronosql_row_codec_test.cpp @@ -0,0 +1,43 @@ +#include +#include +#include +#include + +#include "chronosql_row_codec.h" + +using namespace chronosql; + +TEST(ChronoSQLRowCodec, EncodeDecodeRoundtrip) +{ + std::map row{{"id", "42"}, {"name", "alice"}}; + auto payload = encodeRow(row); + auto decoded = decodeRow(payload); + EXPECT_EQ(decoded, row); +} + +TEST(ChronoSQLRowCodec, EncodeOmitsNothingWhenAllPresent) +{ + std::map row{{"a", "1"}}; + auto payload = encodeRow(row); + EXPECT_NE(payload.find("\"a\":\"1\""), std::string::npos); +} + +TEST(ChronoSQLRowCodec, HandlesEscapedQuotesAndBackslashes) +{ + std::map row{{"k", "she said \"hi\\there\""}}; + auto payload = encodeRow(row); + auto decoded = decodeRow(payload); + EXPECT_EQ(decoded["k"], row["k"]); +} + +TEST(ChronoSQLRowCodec, DecodeRejectsNonObjectRoot) { EXPECT_THROW(decodeRow("[1,2,3]"), std::runtime_error); } + +TEST(ChronoSQLRowCodec, DecodeRejectsInvalidJson) { EXPECT_THROW(decodeRow("not json"), std::runtime_error); } + +TEST(ChronoSQLRowCodec, DecodeStringifiesNonStringValues) +{ + auto decoded = decodeRow(R"({"a": 1, "b": true, "c": null})"); + EXPECT_EQ(decoded["a"], "1"); + EXPECT_EQ(decoded["b"], "true"); + EXPECT_EQ(decoded["c"], "null"); +} diff --git a/test/unit/chronosql/chronosql_sql_parser_test.cpp b/test/unit/chronosql/chronosql_sql_parser_test.cpp new file mode 100644 index 000000000..571d8e818 --- /dev/null +++ b/test/unit/chronosql/chronosql_sql_parser_test.cpp @@ -0,0 +1,137 @@ +#include +#include +#include +#include + +#include "chronosql_sql_parser.h" + +using namespace chronosql; + +TEST(ChronoSQLParser, ParsesCreateTableWithTypes) +{ + auto stmt = parseStatement("CREATE TABLE users (id INT, name STRING, weight DOUBLE)"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->table, "users"); + ASSERT_EQ(p->columns.size(), 3u); + EXPECT_EQ(p->columns[0].name, "id"); + EXPECT_EQ(p->columns[0].type, ColumnType::INT); + EXPECT_EQ(p->columns[1].type, ColumnType::STRING); + EXPECT_EQ(p->columns[2].type, ColumnType::DOUBLE); +} + +TEST(ChronoSQLParser, ParsesCreateTableUntyped) +{ + auto stmt = parseStatement("CREATE TABLE t (a, b, c)"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + ASSERT_EQ(p->columns.size(), 3u); + EXPECT_EQ(p->columns[0].type, ColumnType::STRING); // default +} + +TEST(ChronoSQLParser, ParsesCreateTableVarcharLength) +{ + auto stmt = parseStatement("CREATE TABLE t (a VARCHAR(255), b TEXT)"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->columns[0].type, ColumnType::STRING); + EXPECT_EQ(p->columns[1].type, ColumnType::STRING); +} + +TEST(ChronoSQLParser, ParsesDropTable) +{ + auto stmt = parseStatement("DROP TABLE users;"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->table, "users"); +} + +TEST(ChronoSQLParser, ParsesInsertWithColumns) +{ + auto stmt = parseStatement("INSERT INTO users (id, name) VALUES (42, 'alice')"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + ASSERT_EQ(p->columns.size(), 2u); + EXPECT_EQ(p->columns[0], "id"); + EXPECT_EQ(p->values[0], "42"); + EXPECT_EQ(p->values[1], "alice"); + EXPECT_FALSE(p->is_null[0]); +} + +TEST(ChronoSQLParser, ParsesInsertWithoutColumns) +{ + auto stmt = parseStatement("INSERT INTO users VALUES (1, 'bob', NULL)"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + EXPECT_TRUE(p->columns.empty()); + ASSERT_EQ(p->values.size(), 3u); + EXPECT_TRUE(p->is_null[2]); +} + +TEST(ChronoSQLParser, ParsesInsertEscapedQuote) +{ + auto stmt = parseStatement("INSERT INTO t VALUES ('it''s fine')"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->values[0], "it's fine"); +} + +TEST(ChronoSQLParser, ParsesSelectStar) +{ + auto stmt = parseStatement("SELECT * FROM users"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + EXPECT_TRUE(p->projection.empty()); + EXPECT_TRUE(std::holds_alternative(p->where)); +} + +TEST(ChronoSQLParser, ParsesSelectColumns) +{ + auto stmt = parseStatement("SELECT id, name FROM users"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + ASSERT_EQ(p->projection.size(), 2u); + EXPECT_EQ(p->projection[0], "id"); +} + +TEST(ChronoSQLParser, ParsesSelectWhereEq) +{ + auto stmt = parseStatement("SELECT * FROM users WHERE id = 42"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + auto* w = std::get_if(&p->where); + ASSERT_NE(w, nullptr); + EXPECT_EQ(w->column, "id"); + EXPECT_EQ(w->value, "42"); +} + +TEST(ChronoSQLParser, ParsesSelectWhereTsBetween) +{ + auto stmt = parseStatement("SELECT * FROM users WHERE __ts BETWEEN 100 AND 200"); + auto* p = std::get_if(&stmt); + ASSERT_NE(p, nullptr); + auto* w = std::get_if(&p->where); + ASSERT_NE(w, nullptr); + EXPECT_EQ(w->lo, 100u); + EXPECT_EQ(w->hi, 200u); +} + +TEST(ChronoSQLParser, RejectsUnknownStatement) +{ + EXPECT_THROW(parseStatement("UPDATE users SET name='x'"), std::invalid_argument); +} + +TEST(ChronoSQLParser, RejectsTrailingTokens) +{ + EXPECT_THROW(parseStatement("SELECT * FROM users GARBAGE"), std::invalid_argument); +} + +TEST(ChronoSQLParser, RejectsMissingValues) +{ + EXPECT_THROW(parseStatement("INSERT INTO t (a, b)"), std::invalid_argument); +} + +TEST(ChronoSQLParser, RejectsUnknownType) +{ + EXPECT_THROW(parseStatement("CREATE TABLE t (a FOOBAR)"), std::invalid_argument); +} From 8ef3255179b0dcda955aa804fa310698930776b6 Mon Sep 17 00:00:00 2001 From: Eneko Gonzalez Date: Tue, 5 May 2026 16:52:32 +0000 Subject: [PATCH 2/5] fix(chronosql): tolerate empty metadata story on cold start The integration test aborted in CI because ChronoSQL's first call after construction is a read (getSchema), which triggers a replay of the __chronosql_metadata story. On a fresh deployment that story has no events yet, the player never marks the query complete, and the client hits its 180s replay timeout (CL_ERR_QUERY_TIMED_OUT). The exception propagated past main() and the process was reported as Subprocess aborted. - Add a tolerate_timeout flag to ChronoSQLClientAdapter::replayEvents. When set, CL_ERR_QUERY_TIMED_OUT is logged as a warning and the call returns an empty event list. Used only for the metadata replay path, so data-story replays still surface real failures. - Bump the integration test's post-INSERT propagation wait from 2s to 120s, matching the ChronoKVS integration test. The previous wait was not long enough for the player to make events visible. - Wrap main() in try/catch so connection failures exit cleanly (test treated as skipped, mirroring ChronoKVS) and other exceptions return EXIT_FAILURE instead of aborting the subprocess. --- .../src/chronosql_client_adapter.cpp | 14 ++++++-- .../chronosql/src/chronosql_client_adapter.h | 10 +++++- Plugins/chronosql/src/chronosql_mapper.cpp | 2 +- .../chronosql/chronosql_integration_test.cpp | 34 +++++++++++++++++-- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/Plugins/chronosql/src/chronosql_client_adapter.cpp b/Plugins/chronosql/src/chronosql_client_adapter.cpp index 580f87c30..e3393eb50 100644 --- a/Plugins/chronosql/src/chronosql_client_adapter.cpp +++ b/Plugins/chronosql/src/chronosql_client_adapter.cpp @@ -120,8 +120,10 @@ std::uint64_t ChronoSQLClientAdapter::appendEvent(const std::string& story, cons return handle->log_event(payload); } -std::vector -ChronoSQLClientAdapter::replayEvents(const std::string& story, std::uint64_t start_ts, std::uint64_t end_ts) +std::vector ChronoSQLClientAdapter::replayEvents(const std::string& story, + std::uint64_t start_ts, + std::uint64_t end_ts, + bool tolerate_timeout) { flushCachedHandle(story); @@ -145,6 +147,14 @@ ChronoSQLClientAdapter::replayEvents(const std::string& story, std::uint64_t sta std::vector events; if(int ret = client_->ReplayStory(chronicle_, story, start_ts, end_ts, events); ret != chronolog::CL_SUCCESS) { + if(tolerate_timeout && ret == chronolog::CL_ERR_QUERY_TIMED_OUT) + { + CHRONOSQL_WARNING(logLevel_, + "Replay timed out for story='", + story, + "'; treating as no events available (cold start)"); + return {}; + } CHRONOSQL_ERROR(logLevel_, "Failed to replay events for story='", story, "' code:", ret); throw std::runtime_error("Failed to replay events for story: " + story + ", error code: " + std::to_string(ret)); diff --git a/Plugins/chronosql/src/chronosql_client_adapter.h b/Plugins/chronosql/src/chronosql_client_adapter.h index e8b80064b..dab1ee9df 100644 --- a/Plugins/chronosql/src/chronosql_client_adapter.h +++ b/Plugins/chronosql/src/chronosql_client_adapter.h @@ -46,7 +46,15 @@ class ChronoSQLClientAdapter std::uint64_t appendEvent(const std::string& story, const std::string& payload); /// Replay [start_ts, end_ts) from the named story. - std::vector replayEvents(const std::string& story, std::uint64_t start_ts, std::uint64_t end_ts); + /// + /// On a brand-new deployment a story may have no events committed yet, and + /// the player can fail to mark such a query complete before the client-side + /// replay timeout fires. When `tolerate_timeout` is true, a query timeout + /// is logged as a warning and an empty vector is returned instead of + /// throwing. Use this for read paths that legitimately tolerate "no data + /// yet" (e.g. cold-start metadata replay). + std::vector + replayEvents(const std::string& story, std::uint64_t start_ts, std::uint64_t end_ts, bool tolerate_timeout = false); /// Release all cached write handles. Required before reads if the same /// process holds active write handles for the target story. diff --git a/Plugins/chronosql/src/chronosql_mapper.cpp b/Plugins/chronosql/src/chronosql_mapper.cpp index d8e2c8f1e..ab89f81bb 100644 --- a/Plugins/chronosql/src/chronosql_mapper.cpp +++ b/Plugins/chronosql/src/chronosql_mapper.cpp @@ -35,7 +35,7 @@ void ChronoSQLMapper::ensureMetadataLoaded() { return; } - auto events = adapter_->replayEvents(kMetadataStory, MIN_TS, MAX_TS); + auto events = adapter_->replayEvents(kMetadataStory, MIN_TS, MAX_TS, /*tolerate_timeout=*/true); std::sort(events.begin(), events.end(), [](const ChronoSQLClientAdapter::EventPayload& a, const ChronoSQLClientAdapter::EventPayload& b) diff --git a/test/integration/chronosql/chronosql_integration_test.cpp b/test/integration/chronosql/chronosql_integration_test.cpp index 792260664..825c104e3 100644 --- a/test/integration/chronosql/chronosql_integration_test.cpp +++ b/test/integration/chronosql/chronosql_integration_test.cpp @@ -32,7 +32,7 @@ bool expect_eq(const char* what, std::size_t actual, std::size_t expected) } // namespace -int main(int argc, char** argv) +int run(int argc, char** argv) { std::string conf_path; for(int i = 1; i < argc; ++i) @@ -73,8 +73,11 @@ int main(int argc, char** argv) } ts.push_back(*r.last_insert_timestamp); } + // Release cached write handles so the keeper commits the events, then wait + // long enough for the player to make them visible to replay queries. The + // ChronoKVS integration test uses the same 120s wait for the same reason. db.flush(); - std::this_thread::sleep_for(std::chrono::seconds(2)); + std::this_thread::sleep_for(std::chrono::seconds(120)); bool ok = true; auto all = db.execute("SELECT * FROM " + table); @@ -98,3 +101,30 @@ int main(int argc, char** argv) return ok ? EXIT_SUCCESS : EXIT_FAILURE; } + +int main(int argc, char** argv) +{ + try + { + return run(argc, argv); + } + catch(const std::exception& e) + { + std::string msg(e.what()); + // Mirror the ChronoKVS integration test: when no ChronoLog server is + // reachable, exit cleanly so the test is treated as skipped rather + // than a hard failure. + if(msg.find("Failed to connect") != std::string::npos) + { + std::cout << "ChronoSQL integration test skipped (no ChronoLog server available).\n"; + return EXIT_SUCCESS; + } + std::cerr << "ChronoSQL integration test failed: " << e.what() << "\n"; + return EXIT_FAILURE; + } + catch(...) + { + std::cerr << "ChronoSQL integration test failed: unknown exception\n"; + return EXIT_FAILURE; + } +} From 99ab196cfb74f264cc9e12f2a9d7476d47d8ff20 Mon Sep 17 00:00:00 2001 From: Eneko Gonzalez Date: Tue, 5 May 2026 18:01:49 +0000 Subject: [PATCH 3/5] test(chronosql): raise integration test timeout to 600s The first replay against an unwritten story blocks for the player's hardcoded 180s replay-timeout before returning, and the test then waits 120s for data propagation before the SELECT roundtrips. The previous 300s ctest timeout was too tight and the test was killed mid-run. --- test/integration/chronosql/CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/integration/chronosql/CMakeLists.txt b/test/integration/chronosql/CMakeLists.txt index 3311eb3f2..300796949 100644 --- a/test/integration/chronosql/CMakeLists.txt +++ b/test/integration/chronosql/CMakeLists.txt @@ -31,7 +31,10 @@ add_test(NAME Integration_ChronoSQL_PluginIntegration set_tests_properties(Integration_ChronoSQL_PluginIntegration PROPERTIES MANUAL TRUE - TIMEOUT 300 + # Cold-start budget: ~180s for the initial metadata replay (the ChronoLog + # player times out empty-story queries at its hardcoded 180s ceiling), + # plus 120s for data propagation, plus the SELECT roundtrips. + TIMEOUT 600 ) if(CHRONOLOG_INSTALL_TESTS) From 9a8f212f52aaae2a8c8b4ec5c6afc9ba51fbdb84 Mon Sep 17 00:00:00 2001 From: Eneko Gonzalez Date: Tue, 5 May 2026 19:22:40 +0000 Subject: [PATCH 4/5] refactor(chronosql): rename confManager to client_config Mirror the chronokvs follow-up to PR #628: confManager is a remnant of the old ConfigurationManager class. Use a clearer client-facing name. --- .../src/chronosql_client_adapter.cpp | 28 +++++++++---------- .../chronosql/src/chronosql_client_adapter.h | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Plugins/chronosql/src/chronosql_client_adapter.cpp b/Plugins/chronosql/src/chronosql_client_adapter.cpp index e3393eb50..c472203fd 100644 --- a/Plugins/chronosql/src/chronosql_client_adapter.cpp +++ b/Plugins/chronosql/src/chronosql_client_adapter.cpp @@ -17,8 +17,8 @@ ChronoSQLClientAdapter::ChronoSQLClientAdapter(const std::string& chronicle_name : chronicle_(chronicle_name) , logLevel_(level) { - chronolog::ClientConfiguration confManager; - initialize(confManager); + chronolog::ClientConfiguration client_config; + initialize(client_config); } ChronoSQLClientAdapter::ChronoSQLClientAdapter(const std::string& chronicle_name, @@ -27,29 +27,29 @@ ChronoSQLClientAdapter::ChronoSQLClientAdapter(const std::string& chronicle_name : chronicle_(chronicle_name) , logLevel_(level) { - chronolog::ClientConfiguration confManager; + chronolog::ClientConfiguration client_config; if(!config_path.empty()) { CHRONOSQL_INFO(logLevel_, "Loading ChronoLog client configuration from", config_path); - if(!confManager.load_from_file(config_path)) + if(!client_config.load_from_file(config_path)) { CHRONOSQL_ERROR(logLevel_, "Failed to load configuration file:", config_path); throw std::runtime_error("Failed to load config file: " + config_path); } } - initialize(confManager); + initialize(client_config); } -void ChronoSQLClientAdapter::initialize(const chronolog::ClientConfiguration& confManager) +void ChronoSQLClientAdapter::initialize(const chronolog::ClientConfiguration& client_config) { - chronolog::ClientPortalServiceConf portalConf{confManager.PORTAL_CONF.PROTO_CONF, - confManager.PORTAL_CONF.IP, - confManager.PORTAL_CONF.PORT, - confManager.PORTAL_CONF.PROVIDER_ID}; - chronolog::ClientQueryServiceConf queryConf{confManager.QUERY_CONF.PROTO_CONF, - confManager.QUERY_CONF.IP, - confManager.QUERY_CONF.PORT, - confManager.QUERY_CONF.PROVIDER_ID}; + chronolog::ClientPortalServiceConf portalConf{client_config.PORTAL_CONF.PROTO_CONF, + client_config.PORTAL_CONF.IP, + client_config.PORTAL_CONF.PORT, + client_config.PORTAL_CONF.PROVIDER_ID}; + chronolog::ClientQueryServiceConf queryConf{client_config.QUERY_CONF.PROTO_CONF, + client_config.QUERY_CONF.IP, + client_config.QUERY_CONF.PORT, + client_config.QUERY_CONF.PROVIDER_ID}; CHRONOSQL_INFO(logLevel_, "Connecting to ChronoLog at", portalConf.IP, ":", portalConf.PORT); client_ = std::make_unique(portalConf, queryConf); diff --git a/Plugins/chronosql/src/chronosql_client_adapter.h b/Plugins/chronosql/src/chronosql_client_adapter.h index dab1ee9df..9acce4cd5 100644 --- a/Plugins/chronosql/src/chronosql_client_adapter.h +++ b/Plugins/chronosql/src/chronosql_client_adapter.h @@ -69,7 +69,7 @@ class ChronoSQLClientAdapter std::unordered_map handleCache_; mutable std::mutex cacheMutex_; - void initialize(const chronolog::ClientConfiguration& confManager); + void initialize(const chronolog::ClientConfiguration& client_config); chronolog::StoryHandle* getOrAcquireHandle(const std::string& story); void flushCachedHandle(const std::string& story); }; From 14947b68b5f919197379ba20696189f70a5e350d Mon Sep 17 00:00:00 2001 From: Eneko Gonzalez Date: Tue, 5 May 2026 19:22:48 +0000 Subject: [PATCH 5/5] refactor(chronosql): replace throwing constructors with noexcept Create() factory Mirror the chronokvs follow-up to PR #628: throwing exceptions across a client-library boundary forces callers to wrap construction in try/catch without an obvious set of exception types to catch. Convert the public ChronoSQL API to a factory pattern instead: - Make ChronoSQL constructors private. - Add static std::unique_ptr Create(...) noexcept overloads that catch any construction-path exception, log it via the configured log level, and return nullptr to signal failure. - Update examples, integration test, and unit tests to use the factory and check for nullptr instead of catching std::runtime_error. Internal layers (ChronoSQLMapper, ChronoSQLClientAdapter) keep using exceptions for clarity; they are now caught at the library boundary inside Create() rather than escaping to user code. --- .../examples/chronosql_reader_example.cpp | 11 +++-- .../examples/chronosql_writer_example.cpp | 17 ++++--- Plugins/chronosql/include/chronosql.h | 44 ++++++++++++++----- Plugins/chronosql/src/chronosql.cpp | 36 +++++++++++++++ .../chronosql/chronosql_integration_test.cpp | 34 +++++++------- test/unit/chronosql/chronosql_config_test.cpp | 18 ++++---- 6 files changed, 114 insertions(+), 46 deletions(-) diff --git a/Plugins/chronosql/examples/chronosql_reader_example.cpp b/Plugins/chronosql/examples/chronosql_reader_example.cpp index 17040f1c6..1e3300876 100644 --- a/Plugins/chronosql/examples/chronosql_reader_example.cpp +++ b/Plugins/chronosql/examples/chronosql_reader_example.cpp @@ -10,16 +10,21 @@ int main(int argc, char** argv) { std::string conf_file_path = parse_conf_path_arg(argc, argv); - chronosql::ChronoSQL db = conf_file_path.empty() ? chronosql::ChronoSQL() : chronosql::ChronoSQL(conf_file_path); + auto db = conf_file_path.empty() ? chronosql::ChronoSQL::Create() : chronosql::ChronoSQL::Create(conf_file_path); + if(!db) + { + std::cerr << "Failed to initialize ChronoSQL\n"; + return EXIT_FAILURE; + } - auto schema = db.getSchema("users"); + auto schema = db->getSchema("users"); if(!schema) { std::cerr << "Table 'users' not found. Run the writer example first.\n"; return EXIT_FAILURE; } - auto result = db.execute("SELECT * FROM users"); + auto result = db->execute("SELECT * FROM users"); std::cout << "Found " << result.rows.size() << " rows in 'users':\n"; for(const auto& row: result.rows) { diff --git a/Plugins/chronosql/examples/chronosql_writer_example.cpp b/Plugins/chronosql/examples/chronosql_writer_example.cpp index 1ddc3a118..6c6c85df6 100644 --- a/Plugins/chronosql/examples/chronosql_writer_example.cpp +++ b/Plugins/chronosql/examples/chronosql_writer_example.cpp @@ -14,21 +14,26 @@ int main(int argc, char** argv) // defaults. std::string conf_file_path = parse_conf_path_arg(argc, argv); - chronosql::ChronoSQL db = conf_file_path.empty() ? chronosql::ChronoSQL() : chronosql::ChronoSQL(conf_file_path); + auto db = conf_file_path.empty() ? chronosql::ChronoSQL::Create() : chronosql::ChronoSQL::Create(conf_file_path); + if(!db) + { + std::cerr << "Failed to initialize ChronoSQL\n"; + return EXIT_FAILURE; + } // Programmatic API path: create a small "users" table. - if(!db.getSchema("users").has_value()) + if(!db->getSchema("users").has_value()) { - db.createTable("users", {{"id", chronosql::ColumnType::INT}, {"name", chronosql::ColumnType::STRING}}); + db->createTable("users", {{"id", chronosql::ColumnType::INT}, {"name", chronosql::ColumnType::STRING}}); } // SQL string facade path: insert via execute(). - auto r1 = db.execute("INSERT INTO users (id, name) VALUES (1, 'alice')"); - auto r2 = db.execute("INSERT INTO users VALUES (2, 'bob')"); + auto r1 = db->execute("INSERT INTO users (id, name) VALUES (1, 'alice')"); + auto r2 = db->execute("INSERT INTO users VALUES (2, 'bob')"); std::cout << "Inserted ts=" << r1.last_insert_timestamp.value_or(0) << " and ts=" << r2.last_insert_timestamp.value_or(0) << "\n"; - db.flush(); + db->flush(); return EXIT_SUCCESS; } diff --git a/Plugins/chronosql/include/chronosql.h b/Plugins/chronosql/include/chronosql.h index fbc59f195..0e9b03c7a 100644 --- a/Plugins/chronosql/include/chronosql.h +++ b/Plugins/chronosql/include/chronosql.h @@ -33,28 +33,52 @@ class ChronoSQL std::unique_ptr mapper; LogLevel logLevel_; + // Private constructors. May throw on configuration or connection failure; + // the public Create() factories catch those exceptions at the library + // boundary and signal failure via a nullptr return. + explicit ChronoSQL(LogLevel level); + explicit ChronoSQL(const std::string& config_path, LogLevel level); + public: /** - * @brief Construct a ChronoSQL instance using built-in defaults. + * @brief Create a ChronoSQL instance using built-in default ChronoLog + * client configuration (localhost deployment). + * + * This factory does not propagate exceptions across the library boundary: + * any failure during configuration loading or ChronoLog connection is + * logged at the configured @p level and signalled by returning nullptr. + * + * @param level + * The logging level. Default is DEBUG in debug builds, ERROR in release builds. * - * Uses the localhost ChronoLog client defaults and connects to the default - * chronicle. + * @return std::unique_ptr + * A connected ChronoSQL instance, or nullptr if construction failed. */ - explicit ChronoSQL(LogLevel level = getDefaultLogLevel()); + static std::unique_ptr Create(LogLevel level = getDefaultLogLevel()) noexcept; /** - * @brief Construct a ChronoSQL instance loading a ChronoLog client config. + * @brief Create a ChronoSQL instance loading a ChronoLog client config. + * + * Loads the JSON configuration at @p config_path and uses the resulting portal, + * query and logging settings to connect to ChronoLog. Pass an empty string to + * fall back to the built-in defaults (localhost deployment). + * + * This factory does not propagate exceptions across the library boundary: + * any failure during configuration loading or ChronoLog connection is + * logged at the configured @p level and signalled by returning nullptr. * * @param config_path * Path to a ChronoLog client configuration JSON file. Empty means - * "use defaults" (same as the no-arg constructor). + * "use defaults". * @param level - * The logging level. + * The logging level. Default is DEBUG in debug builds, ERROR in release builds. * - * @throws std::runtime_error if the config file cannot be loaded or the - * ChronoLog client fails to connect. + * @return std::unique_ptr + * A connected ChronoSQL instance, or nullptr if @p config_path could + * not be loaded or the ChronoLog connection failed. */ - explicit ChronoSQL(const std::string& config_path, LogLevel level = getDefaultLogLevel()); + static std::unique_ptr Create(const std::string& config_path, + LogLevel level = getDefaultLogLevel()) noexcept; ~ChronoSQL(); diff --git a/Plugins/chronosql/src/chronosql.cpp b/Plugins/chronosql/src/chronosql.cpp index d658e1a9f..1e3b54233 100644 --- a/Plugins/chronosql/src/chronosql.cpp +++ b/Plugins/chronosql/src/chronosql.cpp @@ -62,6 +62,42 @@ ChronoSQL::ChronoSQL(const std::string& config_path, LogLevel level) , logLevel_(level) {} +std::unique_ptr ChronoSQL::Create(LogLevel level) noexcept +{ + try + { + return std::unique_ptr(new ChronoSQL(level)); + } + catch(const std::exception& e) + { + CHRONOSQL_ERROR(level, "ChronoSQL construction failed: ", e.what()); + return nullptr; + } + catch(...) + { + CHRONOSQL_ERROR(level, "ChronoSQL construction failed: unknown exception"); + return nullptr; + } +} + +std::unique_ptr ChronoSQL::Create(const std::string& config_path, LogLevel level) noexcept +{ + try + { + return std::unique_ptr(new ChronoSQL(config_path, level)); + } + catch(const std::exception& e) + { + CHRONOSQL_ERROR(level, "ChronoSQL construction failed (config_path='", config_path, "'): ", e.what()); + return nullptr; + } + catch(...) + { + CHRONOSQL_ERROR(level, "ChronoSQL construction failed (config_path='", config_path, "'): unknown exception"); + return nullptr; + } +} + ChronoSQL::~ChronoSQL() = default; void ChronoSQL::createTable(const std::string& name, const std::vector& columns) diff --git a/test/integration/chronosql/chronosql_integration_test.cpp b/test/integration/chronosql/chronosql_integration_test.cpp index 825c104e3..6398e9416 100644 --- a/test/integration/chronosql/chronosql_integration_test.cpp +++ b/test/integration/chronosql/chronosql_integration_test.cpp @@ -47,12 +47,17 @@ int run(int argc, char** argv) } } - chronosql::ChronoSQL db = conf_path.empty() ? chronosql::ChronoSQL() : chronosql::ChronoSQL(conf_path); + auto db = conf_path.empty() ? chronosql::ChronoSQL::Create() : chronosql::ChronoSQL::Create(conf_path); + if(!db) + { + std::cout << "ChronoSQL integration test skipped (no ChronoLog server available).\n"; + return EXIT_SUCCESS; + } const std::string table = "ycsb_users"; - if(!db.getSchema(table).has_value()) + if(!db->getSchema(table).has_value()) { - db.execute("CREATE TABLE " + table + " (id INT, name STRING, region STRING)"); + db->execute("CREATE TABLE " + table + " (id INT, name STRING, region STRING)"); } constexpr int N = 100; @@ -65,7 +70,7 @@ int run(int argc, char** argv) std::string region = (i % 2 == 0) ? "us-east" : "us-west"; std::string sql = "INSERT INTO " + table + " (id, name, region) VALUES (" + std::to_string(i) + ", 'user" + std::to_string(i) + "', '" + region + "')"; - auto r = db.execute(sql); + auto r = db->execute(sql); if(!r.last_insert_timestamp.has_value()) { std::cerr << "INSERT did not return a timestamp\n"; @@ -76,23 +81,23 @@ int run(int argc, char** argv) // Release cached write handles so the keeper commits the events, then wait // long enough for the player to make them visible to replay queries. The // ChronoKVS integration test uses the same 120s wait for the same reason. - db.flush(); + db->flush(); std::this_thread::sleep_for(std::chrono::seconds(120)); bool ok = true; - auto all = db.execute("SELECT * FROM " + table); + auto all = db->execute("SELECT * FROM " + table); ok &= expect_eq("SELECT * row count", all.rows.size(), N); - auto east = db.execute("SELECT id, region FROM " + table + " WHERE region = 'us-east'"); + auto east = db->execute("SELECT id, region FROM " + table + " WHERE region = 'us-east'"); ok &= expect_eq("SELECT WHERE region='us-east'", east.rows.size(), N / 2); - auto by_id = db.execute("SELECT * FROM " + table + " WHERE id = 42"); + auto by_id = db->execute("SELECT * FROM " + table + " WHERE id = 42"); ok &= expect_eq("SELECT WHERE id=42", by_id.rows.size(), 1u); if(N >= 10) { - auto range = db.execute("SELECT * FROM " + table + " WHERE __ts BETWEEN " + std::to_string(ts[10]) + " AND " + - std::to_string(ts[19])); + auto range = db->execute("SELECT * FROM " + table + " WHERE __ts BETWEEN " + std::to_string(ts[10]) + " AND " + + std::to_string(ts[19])); ok &= expect_eq("SELECT WHERE __ts BETWEEN ts[10] AND ts[19] (inclusive)", range.rows.size(), 10u); } @@ -110,15 +115,6 @@ int main(int argc, char** argv) } catch(const std::exception& e) { - std::string msg(e.what()); - // Mirror the ChronoKVS integration test: when no ChronoLog server is - // reachable, exit cleanly so the test is treated as skipped rather - // than a hard failure. - if(msg.find("Failed to connect") != std::string::npos) - { - std::cout << "ChronoSQL integration test skipped (no ChronoLog server available).\n"; - return EXIT_SUCCESS; - } std::cerr << "ChronoSQL integration test failed: " << e.what() << "\n"; return EXIT_FAILURE; } diff --git a/test/unit/chronosql/chronosql_config_test.cpp b/test/unit/chronosql/chronosql_config_test.cpp index 96fbf59dd..f4c880ca1 100644 --- a/test/unit/chronosql/chronosql_config_test.cpp +++ b/test/unit/chronosql/chronosql_config_test.cpp @@ -1,20 +1,22 @@ #include #include -#include #include #include -// Smoke tests for the config-file constructor. These run without a live -// ChronoLog deployment: they only exercise the load path that runs before -// any RPC connection attempt. +// Smoke tests for the configuration-file factory. These tests do NOT require +// a running ChronoLog deployment: they only exercise the config-loading path +// that runs *before* any RPC connection attempt. A bad path must cause +// Create() to return nullptr (the library boundary catches the underlying +// exception and signals failure to the caller without forcing them to use +// try/catch). -TEST(ChronoSQLConfig, ThrowsOnNonexistentConfigFile) +TEST(ChronoSQLConfig, ReturnsNullOnNonexistentConfigFile) { - EXPECT_THROW(chronosql::ChronoSQL{"/this/path/should/not/exist/chronosql.json"}, std::runtime_error); + EXPECT_EQ(chronosql::ChronoSQL::Create("/this/path/should/not/exist/chronosql.json"), nullptr); } -TEST(ChronoSQLConfig, ThrowsOnConfigFileMissingChronoClientSection) +TEST(ChronoSQLConfig, ReturnsNullOnConfigFileMissingChronoClientSection) { const std::string path = "/tmp/chronosql_no_section_config.json"; { @@ -23,6 +25,6 @@ TEST(ChronoSQLConfig, ThrowsOnConfigFileMissingChronoClientSection) std::fputs("{}", f); std::fclose(f); } - EXPECT_THROW(chronosql::ChronoSQL{path}, std::runtime_error); + EXPECT_EQ(chronosql::ChronoSQL::Create(path), nullptr); std::remove(path.c_str()); }