Add hydration tests for the ODB ORM. Apparently it works.

This commit is contained in:
2026-03-02 23:49:23 -04:00
parent c72f93efe1
commit c489d4a69e
8 changed files with 362 additions and 0 deletions

View File

@@ -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)

View File

@@ -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<T>`, 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

View File

@@ -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()

View File

@@ -5,3 +5,4 @@ endif()
add_subdirectory(googletest)
add_subdirectory(cpp-serdes)
add_subdirectory(odb-orm)

View File

@@ -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)

View File

@@ -0,0 +1,159 @@
#pragma once
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
#include <gtest/gtest.h>
#include <cppbessot/model/Agent.h>
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<std::filesystem::path> 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<std::filesystem::path> 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 <typename Database, typename Transaction>
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<models::Agent>("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<models::Agent>;
odb::result<models::Agent> results(
db.template query<models::Agent>(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<models::Agent>("agent-orm-1");
db.template erase<models::Agent>("agent-orm-2");
t.commit();
}
{
Transaction t(db.begin());
EXPECT_FALSE(db.template find<models::Agent>("agent-orm-1"));
EXPECT_FALSE(db.template find<models::Agent>("agent-orm-2"));
t.commit();
}
}

View File

@@ -0,0 +1,62 @@
#include <odb/pgsql/database.hxx>
#include <odb/result.hxx>
#include <odb/pgsql/transaction.hxx>
#include <libpq-fe.h>
#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<odb::pgsql::database, odb::pgsql::transaction>(db);
}

View File

@@ -0,0 +1,52 @@
#include <odb/sqlite/database.hxx>
#include <odb/result.hxx>
#include <odb/sqlite/transaction.hxx>
#include <sqlite3.h>
#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<odb::sqlite::database, odb::sqlite::transaction>(db);
}