diff --git a/CMakeLists.txt b/CMakeLists.txt index 1be3d57..64ae3c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,11 @@ if(NOT DEFINED DB_SCHEMA_DIR_TO_GENERATE OR "${DB_SCHEMA_DIR_TO_GENERATE}" STREQ "Set DB_SCHEMA_DIR_TO_GENERATE to the exact schema directory basename to test, for example -DDB_SCHEMA_DIR_TO_GENERATE=v1.2.") endif() +set(CPPBESSOT_ODB_TEST_SQLITE_CONNSTR "" CACHE STRING + "Optional SQLite connection string for ODB runtime tests") +set(CPPBESSOT_ODB_TEST_PGSQL_CONNSTR "" CACHE STRING + "Optional PostgreSQL conninfo string for ODB runtime tests") + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/CppBeSSOT.cmake") if(NOT DEFINED BUILD_TESTING) diff --git a/README.md b/README.md index c1a0e90..537d374 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,23 @@ ctest --test-dir build-tests --output-on-failure The local test fixtures live under `db/test-schema-v1.1` and `db/test-schema-v1.2`. They intentionally differ so migration generation has real additive schema changes to process. +For ODB runtime tests, also provide backend connection strings: + +```bash +cmake -S . -B build-tests \ + -DBUILD_TESTING=ON \ + -DDB_SCHEMA_DIR_TO_GENERATE=test-schema-v1.1 \ + -DCPPBESSOT_ODB_TEST_SQLITE_CONNSTR=/tmp/cppbessot-odb-test.sqlite \ + -DCPPBESSOT_ODB_TEST_PGSQL_CONNSTR="host=127.0.0.1 port=5432 dbname=cppbessot_test user=postgres password=postgres" +cmake --build build-tests --target cpp_odb_orm_sqlite_test_schema_v1_1 +cmake --build build-tests --target cpp_odb_orm_pgsql_test_schema_v1_1 +ctest --test-dir build-tests --output-on-failure +``` + +Use a dedicated PostgreSQL test database. The ODB runtime tests recreate the schema. +The ODB runtime tests also verify hydration from ORM query result sets by persisting multiple rows, querying them back through `odb::result`, and asserting that distinct field values are materialized correctly for both SQLite and PostgreSQL. +The configured CMake connstring values are baked into the test binaries as defaults, so direct binary execution works without exporting environment variables first. If the matching environment variable is set at runtime, it still overrides the compiled default. + ### 3) Link generated libraries ```cmake diff --git a/cmake/dbDependencyCheck.cmake b/cmake/dbDependencyCheck.cmake index def437d..ae4e1d3 100644 --- a/cmake/dbDependencyCheck.cmake +++ b/cmake/dbDependencyCheck.cmake @@ -175,6 +175,18 @@ function(cppbessot_check_dependencies) "PostgreSQL development headers are not usable. Expected to compile with include path `${CPPBESSOT_PGSQL_INCLUDE_DIR}`.") endif() + find_library(CPPBESSOT_SQLITE_CLIENT_LIB NAMES sqlite3 libsqlite3) + if(NOT CPPBESSOT_SQLITE_CLIENT_LIB) + message(FATAL_ERROR + "SQLite client library was not found. On Ubuntu/Debian install package `libsqlite3-dev`.") + endif() + + find_library(CPPBESSOT_PGSQL_CLIENT_LIB NAMES pq libpq) + if(NOT CPPBESSOT_PGSQL_CLIENT_LIB) + message(FATAL_ERROR + "PostgreSQL client library was not found. On Ubuntu/Debian install package `libpq-dev`.") + endif() + set(CPPBESSOT_ODB_EXECUTABLE "${CPPBESSOT_ODB_EXECUTABLE}" PARENT_SCOPE) set(CPPBESSOT_NPX_EXECUTABLE "${CPPBESSOT_NPX_EXECUTABLE}" PARENT_SCOPE) set(CPPBESSOT_NPM_EXECUTABLE "${CPPBESSOT_NPM_EXECUTABLE}" PARENT_SCOPE) @@ -185,5 +197,7 @@ function(cppbessot_check_dependencies) set(CPPBESSOT_ODB_PGSQL_RUNTIME_LIB "${CPPBESSOT_ODB_PGSQL_RUNTIME_LIB}" PARENT_SCOPE) set(CPPBESSOT_SQLITE_INCLUDE_DIR "${CPPBESSOT_SQLITE_INCLUDE_DIR}" PARENT_SCOPE) set(CPPBESSOT_PGSQL_INCLUDE_DIR "${CPPBESSOT_PGSQL_INCLUDE_DIR}" PARENT_SCOPE) + set(CPPBESSOT_SQLITE_CLIENT_LIB "${CPPBESSOT_SQLITE_CLIENT_LIB}" PARENT_SCOPE) + set(CPPBESSOT_PGSQL_CLIENT_LIB "${CPPBESSOT_PGSQL_CLIENT_LIB}" PARENT_SCOPE) set(CPPBESSOT_OPENAPI_ZOD_AVAILABLE TRUE PARENT_SCOPE) endfunction() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 84093d0..c8e9826 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,3 +5,4 @@ endif() add_subdirectory(googletest) add_subdirectory(cpp-serdes) +add_subdirectory(odb-orm) diff --git a/tests/odb-orm/CMakeLists.txt b/tests/odb-orm/CMakeLists.txt new file mode 100644 index 0000000..50af432 --- /dev/null +++ b/tests/odb-orm/CMakeLists.txt @@ -0,0 +1,52 @@ +include(GoogleTest) + +set(CPP_ODB_TEST_NAME_SUFFIX "${DB_SCHEMA_DIR_TO_GENERATE}") +string(REPLACE "." "_" CPP_ODB_TEST_NAME_SUFFIX "${CPP_ODB_TEST_NAME_SUFFIX}") +string(REPLACE "-" "_" CPP_ODB_TEST_NAME_SUFFIX "${CPP_ODB_TEST_NAME_SUFFIX}") + +function(cppbessot_add_odb_orm_test backend target_suffix connstr_var source_file link_target) + if("${${connstr_var}}" STREQUAL "") + message(STATUS "Skipping ${backend} ODB runtime tests because ${connstr_var} is empty.") + return() + endif() + + set(_target_name "cpp_odb_orm_${target_suffix}_${CPP_ODB_TEST_NAME_SUFFIX}") + add_executable(${_target_name} "${source_file}") + add_dependencies(${_target_name} db_gen_sql_ddl) + target_compile_features(${_target_name} PRIVATE cxx_std_20) + target_include_directories(${_target_name} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") + target_link_libraries(${_target_name} + PRIVATE + cppbessot::openai_model_gen + ${link_target} + GTest::gtest_main) + target_compile_definitions(${_target_name} + PRIVATE + CPPBESSOT_ODB_TEST_SQL_DIR="${PROJECT_SOURCE_DIR}/db/${DB_SCHEMA_DIR_TO_GENERATE}/generated-sql-ddl/${backend}" + ${connstr_var}_DEFAULT="${${connstr_var}}") + + if("${backend}" STREQUAL "sqlite") + target_link_libraries(${_target_name} PRIVATE "${CPPBESSOT_SQLITE_CLIENT_LIB}") + elseif("${backend}" STREQUAL "postgre") + target_include_directories(${_target_name} PRIVATE "${CPPBESSOT_PGSQL_INCLUDE_DIR}") + target_link_libraries(${_target_name} PRIVATE "${CPPBESSOT_PGSQL_CLIENT_LIB}") + endif() + + gtest_discover_tests(${_target_name} + PROPERTIES + ENVIRONMENT "${connstr_var}=${${connstr_var}}") +endfunction() + +cppbessot_add_odb_orm_test( + "sqlite" + "sqlite" + CPPBESSOT_ODB_TEST_SQLITE_CONNSTR + "${CMAKE_CURRENT_SOURCE_DIR}/sqlite_orm_test.cpp" + cppbessot::odb_sqlite) + +cppbessot_add_odb_orm_test( + "pgsql" + "pgsql" + CPPBESSOT_ODB_TEST_PGSQL_CONNSTR + "${CMAKE_CURRENT_SOURCE_DIR}/pgsql_orm_test.cpp" + cppbessot::odb_pgsql) diff --git a/tests/odb-orm/orm_test_common.h b/tests/odb-orm/orm_test_common.h new file mode 100644 index 0000000..137fca9 --- /dev/null +++ b/tests/odb-orm/orm_test_common.h @@ -0,0 +1,159 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +inline std::string cppbessot_required_env(const char* name) +{ + const char* value = std::getenv(name); + if(value == nullptr || value[0] == '\0') + { + throw std::runtime_error(std::string("missing required environment variable: ") + name); + } + return value; +} + +inline std::string cppbessot_env_or_default(const char* name, const char* fallback) +{ + const char* value = std::getenv(name); + if(value != nullptr && value[0] != '\0') + { + return value; + } + + if(fallback != nullptr && fallback[0] != '\0') + { + return fallback; + } + + throw std::runtime_error(std::string("missing required environment variable: ") + name); +} + +inline std::vector cppbessot_sql_files() +{ + const std::filesystem::path sql_dir(CPPBESSOT_ODB_TEST_SQL_DIR); + if(!std::filesystem::is_directory(sql_dir)) + { + throw std::runtime_error("SQL DDL directory does not exist: " + sql_dir.string()); + } + + std::vector files; + for(const auto& entry : std::filesystem::directory_iterator(sql_dir)) + { + if(entry.is_regular_file() && entry.path().extension() == ".sql") + { + files.push_back(entry.path()); + } + } + + std::sort(files.begin(), files.end()); + if(files.empty()) + { + throw std::runtime_error("No SQL DDL files found in " + sql_dir.string()); + } + return files; +} + +inline std::string cppbessot_read_text_file(const std::filesystem::path& path) +{ + std::ifstream stream(path); + if(!stream) + { + throw std::runtime_error("Failed to open file: " + path.string()); + } + + std::ostringstream buffer; + buffer << stream.rdbuf(); + return buffer.str(); +} + +template +void cppbessot_run_agent_orm_roundtrip(Database& db) +{ + { + Transaction t(db.begin()); + models::Agent first{}; + first.id = "agent-orm-1"; + first.role = "REQUESTER"; + first.persistent = true; + first.displayName = "ODB Agent"; + db.persist(first); + + models::Agent second{}; + second.id = "agent-orm-2"; + second.role = "PROVIDER"; + second.persistent = false; + second.displayName = "ODB Agent Secondary"; + db.persist(second); + t.commit(); + } + + { + Transaction t(db.begin()); + models::Agent loaded{}; + db.template load("agent-orm-1", loaded); + EXPECT_EQ(loaded.id, "agent-orm-1"); + EXPECT_EQ(loaded.role, "REQUESTER"); + EXPECT_TRUE(loaded.persistent); + EXPECT_EQ(loaded.displayName, "ODB Agent"); + + loaded.displayName = "ODB Agent Updated"; + db.update(loaded); + t.commit(); + } + + { + Transaction t(db.begin()); + using query = odb::query; + odb::result results( + db.template query(query::persistent == true || query::role == "PROVIDER")); + bool saw_updated_agent = false; + bool saw_secondary_agent = false; + std::size_t count = 0; + for(const models::Agent& row : results) + { + ++count; + if(row.id == "agent-orm-1") + { + saw_updated_agent = true; + EXPECT_EQ(row.role, "REQUESTER"); + EXPECT_TRUE(row.persistent); + EXPECT_EQ(row.displayName, "ODB Agent Updated"); + } + else if(row.id == "agent-orm-2") + { + saw_secondary_agent = true; + EXPECT_EQ(row.role, "PROVIDER"); + EXPECT_FALSE(row.persistent); + EXPECT_EQ(row.displayName, "ODB Agent Secondary"); + } + else + { + ADD_FAILURE() << "unexpected hydrated Agent row id: " << row.id; + } + } + EXPECT_EQ(count, 2U); + EXPECT_TRUE(saw_updated_agent); + EXPECT_TRUE(saw_secondary_agent); + + db.template erase("agent-orm-1"); + db.template erase("agent-orm-2"); + t.commit(); + } + + { + Transaction t(db.begin()); + EXPECT_FALSE(db.template find("agent-orm-1")); + EXPECT_FALSE(db.template find("agent-orm-2")); + t.commit(); + } +} diff --git a/tests/odb-orm/pgsql_orm_test.cpp b/tests/odb-orm/pgsql_orm_test.cpp new file mode 100644 index 0000000..69d502d --- /dev/null +++ b/tests/odb-orm/pgsql_orm_test.cpp @@ -0,0 +1,62 @@ +#include +#include +#include +#include + +#include "Agent-odb.hxx" +#include "orm_test_common.h" + +namespace { + +void exec_pgsql(PGconn* conn, const std::string& sql) +{ + PGresult* result = PQexec(conn, sql.c_str()); + if(result == nullptr) + { + throw std::runtime_error("PQexec returned null"); + } + + const ExecStatusType status = PQresultStatus(result); + if(status != PGRES_COMMAND_OK && status != PGRES_TUPLES_OK) + { + const std::string message = PQerrorMessage(conn); + PQclear(result); + throw std::runtime_error(message); + } + + PQclear(result); +} + +void apply_pgsql_ddl(const std::string& connstr) +{ + PGconn* conn = PQconnectdb(connstr.c_str()); + if(PQstatus(conn) != CONNECTION_OK) + { + const std::string message = PQerrorMessage(conn); + PQfinish(conn); + throw std::runtime_error(message); + } + + exec_pgsql(conn, "DROP TABLE IF EXISTS \"TripAttemptResult\" CASCADE;"); + exec_pgsql(conn, "DROP TABLE IF EXISTS \"GovernmentAddress\" CASCADE;"); + exec_pgsql(conn, "DROP TABLE IF EXISTS \"Agent\" CASCADE;"); + + for(const auto& sql_file : cppbessot_sql_files()) + { + exec_pgsql(conn, cppbessot_read_text_file(sql_file)); + } + + PQfinish(conn); +} + +} // namespace + +TEST(PgsqlOdbOrm, PersistsLoadsQueriesAndErases) +{ + const std::string connstr = cppbessot_env_or_default( + "CPPBESSOT_ODB_TEST_PGSQL_CONNSTR", + CPPBESSOT_ODB_TEST_PGSQL_CONNSTR_DEFAULT); + apply_pgsql_ddl(connstr); + odb::pgsql::database db(connstr); + cppbessot_run_agent_orm_roundtrip(db); +} diff --git a/tests/odb-orm/sqlite_orm_test.cpp b/tests/odb-orm/sqlite_orm_test.cpp new file mode 100644 index 0000000..4367221 --- /dev/null +++ b/tests/odb-orm/sqlite_orm_test.cpp @@ -0,0 +1,52 @@ +#include +#include +#include +#include + +#include "Agent-odb.hxx" +#include "orm_test_common.h" + +namespace { + +void apply_sqlite_ddl(const std::string& connstr) +{ + sqlite3* handle = nullptr; + if(sqlite3_open(connstr.c_str(), &handle) != SQLITE_OK) + { + const std::string message = handle != nullptr ? sqlite3_errmsg(handle) : "sqlite open failed"; + if(handle != nullptr) + { + sqlite3_close(handle); + } + throw std::runtime_error(message); + } + + for(const auto& sql_file : cppbessot_sql_files()) + { + const std::string sql = cppbessot_read_text_file(sql_file); + char* error_message = nullptr; + const int rc = sqlite3_exec(handle, sql.c_str(), nullptr, nullptr, &error_message); + if(rc != SQLITE_OK) + { + const std::string message = error_message != nullptr ? error_message : "sqlite3_exec failed"; + sqlite3_free(error_message); + sqlite3_close(handle); + throw std::runtime_error(message + " while applying " + sql_file.string()); + } + } + + sqlite3_close(handle); +} + +} // namespace + +TEST(SqliteOdbOrm, PersistsLoadsQueriesAndErases) +{ + const std::string connstr = cppbessot_env_or_default( + "CPPBESSOT_ODB_TEST_SQLITE_CONNSTR", + CPPBESSOT_ODB_TEST_SQLITE_CONNSTR_DEFAULT); + std::filesystem::remove(connstr); + apply_sqlite_ddl(connstr); + odb::sqlite::database db(connstr, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE); + cppbessot_run_agent_orm_roundtrip(db); +}