Files
metabuilder/dbal/production/tests/unit/sql_utils_test.cpp
2026-03-09 22:30:41 +00:00

844 lines
36 KiB
C++

/**
* Unit tests for SqlQueryBuilder and SqlTypeMapper.
*
* These two headers can be included together safely.
* SqlResultParser and ErrorTranslator are in sql_parser_test.cpp
* (sql_result_parser.hpp conflicts with sql_query_builder.hpp due to
* incompatible EntitySchema using-declarations in the same namespace).
*/
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
#include "sql/sql_query_builder.hpp"
#include "sql/sql_type_mapper.hpp"
#include "adapters/schema_loader.hpp"
#include "adapters/sql_template_generator.hpp"
#include "adapters/sql_generator.hpp"
using namespace dbal::adapters::sql;
using Json = nlohmann::json;
#ifndef DBAL_TEST_TEMPLATE_DIR
# define DBAL_TEST_TEMPLATE_DIR "/dbal/templates/sql"
#endif
// ---------------------------------------------------------------------------
// Helper — dbal::core::EntitySchema (used by SqlQueryBuilder)
// ---------------------------------------------------------------------------
static dbal::core::EntitySchema makeCoreSchema(const std::string& name,
const std::vector<std::string>& fieldNames,
const std::string& fieldType = "string") {
dbal::core::EntitySchema s;
s.name = name;
for (const auto& fn : fieldNames) {
dbal::core::EntityField f;
f.name = fn;
f.type = fieldType;
f.required = false;
s.fields.push_back(f);
}
return s;
}
// ---------------------------------------------------------------------------
// SqlQueryBuilder
// ---------------------------------------------------------------------------
TEST(SqlQueryBuilderTest, BuildInsert_Postgres_ReturningClause) {
auto schema = makeCoreSchema("users", {"id", "createdAt", "name", "email"});
Json data;
data["name"] = "Alice";
data["email"] = "alice@example.com";
auto sql = SqlQueryBuilder::buildInsert("users", schema, data, Dialect::Postgres);
EXPECT_NE(sql.find("INSERT INTO users"), std::string::npos);
EXPECT_NE(sql.find("RETURNING"), std::string::npos);
EXPECT_NE(sql.find("$1"), std::string::npos);
}
TEST(SqlQueryBuilderTest, BuildInsert_MySQL_NoReturning) {
auto schema = makeCoreSchema("users", {"id", "createdAt", "name"});
Json data;
data["name"] = "Bob";
auto sql = SqlQueryBuilder::buildInsert("users", schema, data, Dialect::MySQL);
EXPECT_NE(sql.find("INSERT INTO users"), std::string::npos);
EXPECT_EQ(sql.find("RETURNING"), std::string::npos);
EXPECT_NE(sql.find("?"), std::string::npos);
}
TEST(SqlQueryBuilderTest, BuildInsert_SkipsIdAndCreatedAt) {
auto schema = makeCoreSchema("items", {"id", "createdAt", "title"});
Json data;
data["id"] = "uuid-1";
data["createdAt"] = "2024-01-01";
data["title"] = "Test";
auto sql = SqlQueryBuilder::buildInsert("items", schema, data, Dialect::MySQL);
EXPECT_NE(sql.find("title"), std::string::npos);
EXPECT_EQ(sql.find("createdAt"), std::string::npos);
}
TEST(SqlQueryBuilderTest, BuildSelect_NoFilter) {
auto schema = makeCoreSchema("products", {"id", "name", "price"});
auto sql = SqlQueryBuilder::buildSelect("products", schema, Json::object(), Dialect::Postgres);
EXPECT_NE(sql.find("SELECT"), std::string::npos);
EXPECT_NE(sql.find("FROM products"), std::string::npos);
EXPECT_EQ(sql.find("WHERE"), std::string::npos);
}
TEST(SqlQueryBuilderTest, BuildSelect_WithFilter_Postgres) {
auto schema = makeCoreSchema("products", {"id", "name"});
Json filter;
filter["name"] = "Widget";
auto sql = SqlQueryBuilder::buildSelect("products", schema, filter, Dialect::Postgres);
EXPECT_NE(sql.find("WHERE"), std::string::npos);
EXPECT_NE(sql.find("name = $1"), std::string::npos);
}
TEST(SqlQueryBuilderTest, BuildUpdate_Postgres_HasReturning) {
auto schema = makeCoreSchema("users", {"id", "createdAt", "name", "email"});
Json data;
data["name"] = "Updated";
auto sql = SqlQueryBuilder::buildUpdate("users", schema, "uuid-1", data, Dialect::Postgres);
EXPECT_NE(sql.find("UPDATE users SET"), std::string::npos);
EXPECT_NE(sql.find("RETURNING"), std::string::npos);
EXPECT_NE(sql.find("WHERE id = $1"), std::string::npos);
}
TEST(SqlQueryBuilderTest, BuildUpdate_NoFields_ReturnsEmpty) {
auto schema = makeCoreSchema("users", {"id", "createdAt"});
Json data;
auto sql = SqlQueryBuilder::buildUpdate("users", schema, "uuid-1", data, Dialect::MySQL);
EXPECT_TRUE(sql.empty());
}
TEST(SqlQueryBuilderTest, BuildDelete_MySQL_Placeholder) {
auto sql = SqlQueryBuilder::buildDelete("sessions", "sid", Dialect::MySQL);
EXPECT_NE(sql.find("DELETE FROM sessions"), std::string::npos);
EXPECT_NE(sql.find("WHERE id = ?"), std::string::npos);
}
TEST(SqlQueryBuilderTest, BuildList_WithPagination) {
auto schema = makeCoreSchema("items", {"id", "createdAt", "title"});
dbal::ListOptions opts;
opts.limit = 10;
opts.page = 3;
auto sql = SqlQueryBuilder::buildList("items", schema, opts, Dialect::Postgres);
EXPECT_NE(sql.find("ORDER BY createdAt DESC"), std::string::npos);
EXPECT_NE(sql.find("LIMIT"), std::string::npos);
EXPECT_NE(sql.find("OFFSET"), std::string::npos);
}
TEST(SqlQueryBuilderTest, BuildList_WithFilter_MySQL) {
auto schema = makeCoreSchema("users", {"id", "status"});
dbal::ListOptions opts;
opts.filter["status"] = "active";
opts.limit = 20;
auto sql = SqlQueryBuilder::buildList("users", schema, opts, Dialect::MySQL);
EXPECT_NE(sql.find("WHERE"), std::string::npos);
EXPECT_NE(sql.find("status = ?"), std::string::npos);
}
// ---------------------------------------------------------------------------
// SqlTypeMapper
// ---------------------------------------------------------------------------
TEST(SqlTypeMapperTest, YamlToSqlType_String_ReturnsVarchar) {
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("string", Dialect::Postgres), "VARCHAR(255)");
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("string", Dialect::MySQL), "VARCHAR(255)");
}
TEST(SqlTypeMapperTest, YamlToSqlType_Boolean_MySQL_TinyInt) {
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("boolean", Dialect::MySQL), "TINYINT(1)");
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("boolean", Dialect::Postgres), "BOOLEAN");
}
TEST(SqlTypeMapperTest, YamlToSqlType_Json_Postgres_JSONB) {
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("json", Dialect::Postgres), "JSONB");
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("json", Dialect::MySQL), "JSON");
}
TEST(SqlTypeMapperTest, YamlToSqlType_UUID_Postgres_UUID) {
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("uuid", Dialect::Postgres), "UUID");
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("uuid", Dialect::MySQL), "VARCHAR(36)");
}
TEST(SqlTypeMapperTest, YamlToSqlType_DateTime_MySQL_DATETIME) {
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("datetime", Dialect::MySQL), "DATETIME");
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("date", Dialect::Postgres), "TIMESTAMP");
}
TEST(SqlTypeMapperTest, YamlToSqlType_Integer_ReturnsINTEGER) {
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("number", Dialect::Postgres), "INTEGER");
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("integer", Dialect::MySQL), "INTEGER");
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("int", Dialect::Postgres), "INTEGER");
}
TEST(SqlTypeMapperTest, YamlToSqlType_Bigint_ReturnsBIGINT) {
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("bigint", Dialect::Postgres), "BIGINT");
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("bigint", Dialect::MySQL), "BIGINT");
}
TEST(SqlTypeMapperTest, YamlToSqlType_Text_ReturnsTEXT) {
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("text", Dialect::Postgres), "TEXT");
}
TEST(SqlTypeMapperTest, YamlToSqlType_Unknown_FallsBackToVarchar) {
EXPECT_EQ(SqlTypeMapper::yamlTypeToSqlType("custom_type", Dialect::Postgres), "VARCHAR(255)");
}
TEST(SqlTypeMapperTest, JsonValueToString_Null_ReturnsEmpty) {
EXPECT_EQ(SqlTypeMapper::jsonValueToString(nullptr), "");
}
TEST(SqlTypeMapperTest, JsonValueToString_Bool) {
EXPECT_EQ(SqlTypeMapper::jsonValueToString(true), "true");
EXPECT_EQ(SqlTypeMapper::jsonValueToString(false), "false");
}
TEST(SqlTypeMapperTest, JsonValueToString_Number) {
EXPECT_EQ(SqlTypeMapper::jsonValueToString(Json(42)), "42");
}
TEST(SqlTypeMapperTest, JsonValueToString_String) {
EXPECT_EQ(SqlTypeMapper::jsonValueToString(Json("hello")), "hello");
}
TEST(SqlTypeMapperTest, JsonValueToString_Object_Dumps) {
Json obj; obj["key"] = "val";
EXPECT_FALSE(SqlTypeMapper::jsonValueToString(obj).empty());
}
TEST(SqlTypeMapperTest, SqlValueToJson_Empty_ReturnsNull) {
EXPECT_TRUE(SqlTypeMapper::sqlValueToJson("", "string").is_null());
}
TEST(SqlTypeMapperTest, SqlValueToJson_Boolean_Various) {
EXPECT_TRUE(SqlTypeMapper::sqlValueToJson("1", "boolean").get<bool>());
EXPECT_TRUE(SqlTypeMapper::sqlValueToJson("t", "boolean").get<bool>());
EXPECT_TRUE(SqlTypeMapper::sqlValueToJson("true", "boolean").get<bool>());
EXPECT_TRUE(SqlTypeMapper::sqlValueToJson("TRUE", "boolean").get<bool>());
EXPECT_FALSE(SqlTypeMapper::sqlValueToJson("0", "boolean").get<bool>());
}
TEST(SqlTypeMapperTest, SqlValueToJson_Numeric) {
EXPECT_EQ(SqlTypeMapper::sqlValueToJson("123", "number").get<int64_t>(), 123LL);
}
TEST(SqlTypeMapperTest, SqlValueToJson_String_PassThrough) {
EXPECT_EQ(SqlTypeMapper::sqlValueToJson("hello", "string").get<std::string>(), "hello");
}
TEST(SqlTypeMapperTest, ToSnakeCase_PascalCase) {
EXPECT_EQ(SqlTypeMapper::toSnakeCase("UserProfile"), "user_profile");
EXPECT_EQ(SqlTypeMapper::toSnakeCase("createdAt"), "created_at");
EXPECT_EQ(SqlTypeMapper::toSnakeCase("id"), "id");
}
// ===========================================================================
// SqlTemplateGenerator — multi-dialect CREATE TABLE generation
// Exercises MySQL/PostgreSQL branches not covered by SQLite integration tests.
// ===========================================================================
// Build a minimal EntityDefinition for template tests
static dbal::adapters::EntityDefinition makeTemplateEntity(const std::string& name = "Widget") {
dbal::adapters::EntityDefinition e;
e.name = name;
e.version = "1.0.0";
dbal::adapters::FieldDefinition id;
id.name = "id"; id.type = "uuid"; id.primary = true; id.generated = true;
e.fields.push_back(id);
dbal::adapters::FieldDefinition label;
label.name = "label"; label.type = "string"; label.required = true; label.max_length = 100;
e.fields.push_back(label);
dbal::adapters::FieldDefinition active;
active.name = "active"; active.type = "boolean"; active.default_value = "true";
e.fields.push_back(active);
dbal::adapters::FieldDefinition status;
status.name = "status"; status.type = "enum";
status.enum_values = {"draft", "published"};
e.fields.push_back(status);
dbal::adapters::FieldDefinition score;
score.name = "score"; score.type = "integer"; score.default_value = "0";
e.fields.push_back(score);
dbal::adapters::FieldDefinition ts;
ts.name = "createdAt"; ts.type = "timestamp";
e.fields.push_back(ts);
dbal::adapters::FieldDefinition meta;
meta.name = "meta"; meta.type = "json";
e.fields.push_back(meta);
dbal::adapters::IndexDefinition idx;
idx.fields = {"label"}; idx.unique = true;
e.indexes.push_back(idx);
return e;
}
// Parameterized: generate CREATE TABLE for each dialect
struct DialectCase { dbal::adapters::SqlDialect dialect; std::string expectedFragment; };
class TemplateGenTest : public testing::TestWithParam<DialectCase> {};
TEST_P(TemplateGenTest, GenerateCreateTable_NonEmpty) {
auto p = GetParam();
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
auto entity = makeTemplateEntity("Item");
auto sql = gen.generateCreateTable(entity, p.dialect);
EXPECT_FALSE(sql.empty());
EXPECT_NE(sql.find(p.expectedFragment), std::string::npos);
}
INSTANTIATE_TEST_SUITE_P(AllDialects, TemplateGenTest, testing::Values(
DialectCase{dbal::adapters::SqlDialect::SQLite, "Item"},
DialectCase{dbal::adapters::SqlDialect::PostgreSQL, "Item"},
DialectCase{dbal::adapters::SqlDialect::MySQL, "Item"}
));
TEST(SqlTemplateGeneratorTest, GenerateIndexes_PostgreSQL_ReturnsIndexSQL) {
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
auto entity = makeTemplateEntity("Product");
auto indexes = gen.generateIndexes(entity, dbal::adapters::SqlDialect::PostgreSQL);
EXPECT_FALSE(indexes.empty());
}
TEST(SqlTemplateGeneratorTest, GenerateIndexes_MySQL_ReturnsIndexSQL) {
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
auto entity = makeTemplateEntity("Order");
auto indexes = gen.generateIndexes(entity, dbal::adapters::SqlDialect::MySQL);
EXPECT_FALSE(indexes.empty());
}
TEST(SqlTemplateGeneratorTest, EntityWithMinLength_GeneratesCheck) {
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
dbal::adapters::EntityDefinition e;
e.name = "Doc"; e.version = "1.0.0";
dbal::adapters::FieldDefinition f;
f.name = "title"; f.type = "string"; f.min_length = 3; f.max_length = 50;
e.fields.push_back(f);
// Just verify it doesn't throw; CHECK constraint generation is exercised
EXPECT_NO_THROW(gen.generateCreateTable(e, dbal::adapters::SqlDialect::PostgreSQL));
}
TEST(SqlTemplateGeneratorTest, AlterAddColumn_PostgreSQL_ProducesSQL) {
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
auto entity = makeTemplateEntity("Event");
EXPECT_NO_THROW(gen.generateAlterAddColumn(entity, entity.fields[1],
dbal::adapters::SqlDialect::PostgreSQL));
}
TEST(SqlTemplateGeneratorTest, AlterAddColumn_MySQL_ProducesSQL) {
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
auto entity = makeTemplateEntity("Event");
EXPECT_NO_THROW(gen.generateAlterAddColumn(entity, entity.fields[1],
dbal::adapters::SqlDialect::MySQL));
}
// Additional template generator tests for remaining uncovered branches
TEST(SqlTemplateGeneratorTest, BooleanDefaultForPostgres_InOutput) {
// Covers PG-specific boolean default branch (true/false string)
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
dbal::adapters::EntityDefinition e;
e.name = "Flag"; e.version = "1.0.0";
dbal::adapters::FieldDefinition f;
f.name = "enabled"; f.type = "boolean"; f.default_value = "true";
e.fields.push_back(f);
EXPECT_NO_THROW(gen.generateCreateTable(e, dbal::adapters::SqlDialect::PostgreSQL));
}
TEST(SqlTemplateGeneratorTest, CreatedAtTimestampDefault_AllDialects) {
// Covers createdAt auto-default branches for MySQL and PostgreSQL
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
dbal::adapters::EntityDefinition e;
e.name = "Event"; e.version = "1.0.0";
dbal::adapters::FieldDefinition f;
f.name = "createdAt"; f.type = "timestamp"; // no default_value → auto-generated
e.fields.push_back(f);
EXPECT_NO_THROW(gen.generateCreateTable(e, dbal::adapters::SqlDialect::MySQL));
EXPECT_NO_THROW(gen.generateCreateTable(e, dbal::adapters::SqlDialect::PostgreSQL));
}
TEST(SqlTemplateGeneratorTest, IndexSkip_WhenFieldAlreadyUnique) {
// Covers index-skip branch: unique index on field that is already @unique
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
dbal::adapters::EntityDefinition e;
e.name = "Obj"; e.version = "1.0.0";
dbal::adapters::FieldDefinition f;
f.name = "email"; f.type = "string"; f.unique = true;
e.fields.push_back(f);
dbal::adapters::IndexDefinition idx;
idx.fields = {"email"}; idx.unique = true; // will be skipped since field is already unique
e.indexes.push_back(idx);
auto indexes = gen.generateIndexes(e, dbal::adapters::SqlDialect::PostgreSQL);
// Index should be skipped since field already has UNIQUE
EXPECT_TRUE(indexes.empty());
}
TEST(SqlTemplateGeneratorTest, AlterAddColumn_SQLite_ProducesSQL) {
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
auto entity = makeTemplateEntity("Log");
EXPECT_NO_THROW(gen.generateAlterAddColumn(entity, entity.fields[1],
dbal::adapters::SqlDialect::SQLite));
}
TEST(SqlTemplateGeneratorTest, EntityWithJsonField_PostgreSQL_UsesJsonb) {
// Covers the JSONB return for PostgreSQL
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
dbal::adapters::EntityDefinition e;
e.name = "Config"; e.version = "1.0.0";
dbal::adapters::FieldDefinition f;
f.name = "data"; f.type = "json";
e.fields.push_back(f);
EXPECT_NO_THROW(gen.generateCreateTable(e, dbal::adapters::SqlDialect::PostgreSQL));
}
// ===========================================================================
// SchemaLoader — loadFromFile and loadFromDirectory
// ===========================================================================
#include "adapters/schema_loader.hpp"
TEST(SchemaLoaderTest, LoadFromFile_NonExistent_ReturnsNullopt) {
auto result = dbal::adapters::SchemaLoader::loadFromFile("/nonexistent/entity.json");
EXPECT_FALSE(result.has_value());
}
TEST(SchemaLoaderTest, LoadFromFile_ValidJson_WithEntityKey) {
auto tmp = std::filesystem::temp_directory_path() / "test_entity.json";
std::ofstream(tmp) << R"({"entity":"Widget","version":"1.0","fields":{"id":{"type":"uuid","primary":true},"name":{"type":"string"}}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
EXPECT_EQ(result->name, "Widget");
EXPECT_FALSE(result->fields.empty());
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromFile_WithDisplayNameKey) {
auto tmp = std::filesystem::temp_directory_path() / "test_dn.json";
std::ofstream(tmp) << R"({"displayName":"Product","fields":{}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
EXPECT_EQ(result->name, "Product");
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromFile_WithNameKey_Capitalized) {
auto tmp = std::filesystem::temp_directory_path() / "test_name.json";
std::ofstream(tmp) << R"({"name":"order","fields":{}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
EXPECT_EQ(result->name, "Order"); // first letter capitalized
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromFile_ArrayOfEntities_UsesFirst) {
auto tmp = std::filesystem::temp_directory_path() / "test_arr.json";
std::ofstream(tmp) << R"([{"entity":"First","fields":{}},{"entity":"Second","fields":{}}])";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
EXPECT_EQ(result->name, "First");
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromFile_WithIndexes) {
auto tmp = std::filesystem::temp_directory_path() / "test_idx.json";
std::ofstream(tmp) << R"({"entity":"Post","fields":{"title":{"type":"string"}},"indexes":[{"fields":["title"],"unique":true}]})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
EXPECT_FALSE(result->indexes.empty());
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromFile_WithRelations) {
auto tmp = std::filesystem::temp_directory_path() / "test_rel.json";
std::ofstream(tmp) << R"({"entity":"Comment","fields":{"id":{"type":"uuid"}},"relations":{"post":{"type":"belongs-to","entity":"Post","foreign_key":"postId"}}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
EXPECT_FALSE(result->relations.empty());
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromDirectory_EmptyDir_ReturnsEmpty) {
auto dir = std::filesystem::temp_directory_path() / "dbal_schema_scan";
std::filesystem::create_directories(dir);
auto result = dbal::adapters::SchemaLoader::loadFromDirectory(dir.string());
EXPECT_TRUE(result.empty());
std::filesystem::remove_all(dir);
}
// More SchemaLoader tests to cover field-processing branches
TEST(SchemaLoaderTest, LoadFromFile_NoNameKey_ReturnsNullopt) {
auto tmp = std::filesystem::temp_directory_path() / "no_name.json";
std::ofstream(tmp) << R"({"version":"1.0","fields":{}})"; // no entity/displayName/name
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
EXPECT_FALSE(result.has_value());
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromFile_RelationshipField_Skipped) {
auto tmp = std::filesystem::temp_directory_path() / "rel_field.json";
std::ofstream(tmp) << R"({"entity":"Post","fields":{"author":{"type":"relationship"},"title":{"type":"string"}}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
// relationship field should be skipped
for (const auto& f : result->fields)
EXPECT_NE(f.name, "author");
}
TEST(SchemaLoaderTest, LoadFromFile_DatetimeAndNumberTypes_Remapped) {
auto tmp = std::filesystem::temp_directory_path() / "remapped.json";
std::ofstream(tmp) << R"({"entity":"Event","fields":{"ts":{"type":"datetime"},"amt":{"type":"number"}}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
for (const auto& f : result->fields) {
EXPECT_EQ(f.type, "bigint"); // both datetime and number map to bigint
}
}
TEST(SchemaLoaderTest, LoadFromFile_FieldWithPrimaryKey_AndConstraints) {
auto tmp = std::filesystem::temp_directory_path() / "pk_field.json";
std::ofstream(tmp) << R"({"entity":"Article","fields":{"id":{"type":"string","primaryKey":true,"generated":true},"slug":{"type":"string","unique":true,"required":true,"min_length":3,"max_length":200,"pattern":"^[a-z]","values":["draft","pub"]}}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
bool found_id = false, found_slug = false;
for (const auto& f : result->fields) {
if (f.name == "id") { EXPECT_TRUE(f.primary); EXPECT_TRUE(f.generated); found_id = true; }
if (f.name == "slug") {
EXPECT_TRUE(f.unique); EXPECT_TRUE(f.required);
EXPECT_EQ(f.min_length, 3); EXPECT_EQ(f.max_length, 200);
EXPECT_TRUE(f.pattern.has_value());
EXPECT_FALSE(f.enum_values.empty());
found_slug = true;
}
}
EXPECT_TRUE(found_id); EXPECT_TRUE(found_slug);
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromFile_NonStringDefault_Converted) {
auto tmp = std::filesystem::temp_directory_path() / "numdef.json";
std::ofstream(tmp) << R"({"entity":"Thing","fields":{"count":{"type":"integer","default":42}}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
for (const auto& f : result->fields)
if (f.name == "count") EXPECT_TRUE(f.default_value.has_value());
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromFile_TenantIdAutoAdd) {
auto tmp = std::filesystem::temp_directory_path() / "tenant.json";
std::ofstream(tmp) << R"({"entity":"Item","tenantId":true,"fields":{"id":{"type":"uuid"}}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
bool has_tenant = false;
for (const auto& f : result->fields)
if (f.name == "tenantId") { has_tenant = true; break; }
EXPECT_TRUE(has_tenant); // tenantId auto-added
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromFile_WithQueryConfig) {
auto tmp = std::filesystem::temp_directory_path() / "qcfg.json";
std::ofstream(tmp) << R"({"entity":"Log","fields":{},"query":{"allowed_operators":["eq","gt"],"max_results":50,"timeout_ms":5000}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
EXPECT_EQ(result->query_config.max_results, 50);
EXPECT_EQ(result->query_config.timeout_ms, 5000);
EXPECT_FALSE(result->query_config.allowed_operators.empty());
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromDirectory_WithJsonFiles_ReturnsEntities) {
auto dir = std::filesystem::temp_directory_path() / "dbal_schema_dir";
std::filesystem::create_directories(dir);
std::ofstream(dir / "user.json") << R"({"entity":"User","fields":{"id":{"type":"uuid","primary":true}}})";
std::ofstream(dir / "post.json") << R"({"entity":"Post","fields":{"id":{"type":"uuid","primary":true}}})";
auto result = dbal::adapters::SchemaLoader::loadFromDirectory(dir.string());
EXPECT_EQ(result.size(), 2u);
std::filesystem::remove_all(dir);
}
// ===========================================================================
// SqlWhereBuilder — additional operator coverage (Ne, Lt, Lte, Gt, ILike, etc.)
// ===========================================================================
#include "sql/sql_where_builder.hpp"
#include "sql/sql_types.hpp"
static std::string buildWhere(
const std::vector<dbal::FilterCondition>& conditions,
const std::string& dialect = "sqlite")
{
std::unordered_set<std::string> valid{"age","name","status","count","score"};
std::vector<dbal::adapters::sql::SqlParam> params;
int idx = 1;
return dbal::adapters::sql::SqlWhereBuilder::build(
conditions, {}, {}, valid, dialect, params, idx);
}
TEST(SqlWhereBuilderTest, Ne_ProducesNotEqualClause) {
dbal::FilterCondition c; c.field = "status"; c.op = dbal::FilterOp::Ne; c.value = "deleted";
EXPECT_NE(buildWhere({c}).find("<>"), std::string::npos);
}
TEST(SqlWhereBuilderTest, Lt_ProducesLessThan) {
dbal::FilterCondition c; c.field = "age"; c.op = dbal::FilterOp::Lt; c.value = "30";
EXPECT_NE(buildWhere({c}).find(" < "), std::string::npos);
}
TEST(SqlWhereBuilderTest, Lte_ProducesLessThanOrEqual) {
dbal::FilterCondition c; c.field = "age"; c.op = dbal::FilterOp::Lte; c.value = "30";
EXPECT_NE(buildWhere({c}).find(" <= "), std::string::npos);
}
TEST(SqlWhereBuilderTest, Gt_ProducesGreaterThan) {
dbal::FilterCondition c; c.field = "score"; c.op = dbal::FilterOp::Gt; c.value = "100";
EXPECT_NE(buildWhere({c}).find(" > "), std::string::npos);
}
TEST(SqlWhereBuilderTest, ILike_MySQL_ProducesLower) {
dbal::FilterCondition c; c.field = "name"; c.op = dbal::FilterOp::ILike; c.value = "%alice%";
auto sql = buildWhere({c}, "mysql");
EXPECT_NE(sql.find("LOWER"), std::string::npos);
}
TEST(SqlWhereBuilderTest, IsNotNull_ProducesIsNotNull) {
dbal::FilterCondition c; c.field = "name"; c.op = dbal::FilterOp::IsNotNull;
EXPECT_NE(buildWhere({c}).find("IS NOT NULL"), std::string::npos);
}
TEST(SqlWhereBuilderTest, Between_ProducesBetweenClause) {
dbal::FilterCondition c; c.field = "age"; c.op = dbal::FilterOp::Between;
c.values = {"18", "65"};
EXPECT_NE(buildWhere({c}).find("BETWEEN"), std::string::npos);
}
TEST(SqlTypeMapperTest, SqlValueToJson_InvalidNumericString_ReturnsNull) {
// sql_type_mapper.cpp lines 72-74: stoll throws → catch → return nullptr
auto result = SqlTypeMapper::sqlValueToJson("not-a-number", "number");
EXPECT_TRUE(result.is_null());
}
TEST(SqlWhereBuilderTest, Gte_ProducesGreaterThanOrEqual) {
// sql_where_builder.hpp lines 198-199
dbal::FilterCondition c; c.field = "score"; c.op = dbal::FilterOp::Gte; c.value = "100";
EXPECT_NE(buildWhere({c}).find(" >= "), std::string::npos);
}
TEST(SqlWhereBuilderTest, AggregateSelect_CoversSumAvgMinMax) {
// sql_where_builder.hpp lines 239-242: aggFuncName Sum/Avg/Min/Max branches
std::unordered_set<std::string> valid{"amount", "price"};
struct Case { dbal::AggFunc func; std::string expected; };
for (auto tc : std::vector<Case>{
{dbal::AggFunc::Sum, "SUM"},
{dbal::AggFunc::Avg, "AVG"},
{dbal::AggFunc::Min, "MIN"},
{dbal::AggFunc::Max, "MAX"},
}) {
dbal::AggregateSpec spec; spec.func = tc.func; spec.field = "amount"; spec.alias = "result";
auto sql = dbal::adapters::sql::SqlWhereBuilder::buildAggregateSelect({spec}, {}, valid, "sqlite");
EXPECT_NE(sql.find(tc.expected), std::string::npos) << "Expected " << tc.expected;
}
}
TEST(SchemaLoaderTest, LoadFromFile_StringDefault_Covered) {
// schema_loader.hpp line 119: def_node.is_string() branch
auto tmp = std::filesystem::temp_directory_path() / "strdef.json";
std::ofstream(tmp) << R"({"entity":"Item","fields":{"status":{"type":"string","default":"active"}}})";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
ASSERT_TRUE(result.has_value());
for (const auto& f : result->fields)
if (f.name == "status") EXPECT_EQ(f.default_value.value_or(""), "active");
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromFile_MalformedJson_ReturnsNullopt) {
// schema_loader.hpp lines 200-201: json::parse_error catch
auto tmp = std::filesystem::temp_directory_path() / "bad.json";
std::ofstream(tmp) << "{ this is not valid json !!!";
auto result = dbal::adapters::SchemaLoader::loadFromFile(tmp.string());
EXPECT_FALSE(result.has_value());
std::filesystem::remove(tmp);
}
TEST(SchemaLoaderTest, LoadFromDirectory_NameVariants) {
// schema_loader.hpp lines 226-228: displayName / name / no-name-key (skip)
auto dir = std::filesystem::temp_directory_path() / "dbal_dir_names";
std::filesystem::create_directories(dir);
// Uses displayName (line 226)
std::ofstream(dir/"dn.json") << R"({"displayName":"Alpha","fields":{}})";
// Uses name key (line 227) — gets capitalized
std::ofstream(dir/"nm.json") << R"({"name":"beta","fields":{}})";
// No name key — should be skipped (line 228)
std::ofstream(dir/"skip.json") << R"({"version":"1.0","fields":{}})";
auto result = dbal::adapters::SchemaLoader::loadFromDirectory(dir.string());
EXPECT_EQ(result.size(), 2u); // skip.json entity is skipped
std::filesystem::remove_all(dir);
}
TEST(SchemaLoaderTest, LoadFromDirectory_WithRelationsAndQueryConfig) {
// schema_loader.hpp lines 261-270 (relations) and 272-282 (query config)
auto dir = std::filesystem::temp_directory_path() / "dbal_dir_relq";
std::filesystem::create_directories(dir);
std::ofstream(dir/"post.json") << R"({
"entity":"Post",
"fields":{"id":{"type":"uuid"}},
"relations":{"author":{"type":"belongs-to","entity":"User","foreign_key":"userId"}},
"query":{"allowed_operators":["eq","gt"],"allowed_group_by":["id"],
"allowed_includes":["author"],"max_results":100,"timeout_ms":3000}
})";
auto result = dbal::adapters::SchemaLoader::loadFromDirectory(dir.string());
ASSERT_EQ(result.size(), 1u);
EXPECT_FALSE(result[0].relations.empty());
EXPECT_EQ(result[0].query_config.max_results, 100);
EXPECT_EQ(result[0].query_config.timeout_ms, 3000);
EXPECT_FALSE(result[0].query_config.allowed_operators.empty());
std::filesystem::remove_all(dir);
}
TEST(SqlWhereBuilderTest, OrGroup_SingleCondition_NoExtraParens) {
// Covers or_parts.size() == 1 branch (lines 75-76)
std::unordered_set<std::string> valid{"status"};
std::vector<dbal::adapters::sql::SqlParam> params;
int idx = 1;
dbal::FilterCondition c; c.field = "status"; c.op = dbal::FilterOp::Eq; c.value = "active";
dbal::FilterGroup grp; grp.conditions = {c};
auto sql = dbal::adapters::sql::SqlWhereBuilder::build(
{}, {grp}, {}, valid, "sqlite", params, idx);
EXPECT_FALSE(sql.empty());
}
// ===========================================================================
// MySqlErrorMapper — inline mapMySqlError() function (mysql_error_mapper.hpp)
// sql_types.hpp already included above, so no redefinition.
// ===========================================================================
#include "sql/mysql_error_mapper.hpp"
#include "sql/postgres_error_mapper.hpp"
TEST(MySqlErrorMapperTest, UniqueViolation_1062) {
EXPECT_EQ(mapMySqlError(1062), SqlError::Code::UniqueViolation);
}
TEST(MySqlErrorMapperTest, UniqueViolationWithKey_1586) {
EXPECT_EQ(mapMySqlError(1586), SqlError::Code::UniqueViolation);
}
TEST(MySqlErrorMapperTest, ForeignKey_1451_And_1452) {
EXPECT_EQ(mapMySqlError(1451), SqlError::Code::ForeignKeyViolation);
EXPECT_EQ(mapMySqlError(1452), SqlError::Code::ForeignKeyViolation);
}
TEST(MySqlErrorMapperTest, NotFound_1146) {
EXPECT_EQ(mapMySqlError(1146), SqlError::Code::NotFound);
}
TEST(MySqlErrorMapperTest, Timeout_1205) {
EXPECT_EQ(mapMySqlError(1205), SqlError::Code::Timeout);
}
TEST(MySqlErrorMapperTest, ConnectionLost_2006_And_2013) {
EXPECT_EQ(mapMySqlError(2006), SqlError::Code::ConnectionLost);
EXPECT_EQ(mapMySqlError(2013), SqlError::Code::ConnectionLost);
}
TEST(MySqlErrorMapperTest, Unknown_Fallthrough) {
EXPECT_EQ(mapMySqlError(9999), SqlError::Code::Unknown);
}
// ===========================================================================
// PostgresErrorMapper — inline mapPgSqlState() function
// ===========================================================================
TEST(PgErrorMapperTest, NullState_IsUnknown) {
EXPECT_EQ(mapPgSqlState(nullptr), SqlError::Code::Unknown);
}
TEST(PgErrorMapperTest, UniqueViolation_23505) {
EXPECT_EQ(mapPgSqlState("23505"), SqlError::Code::UniqueViolation);
}
TEST(PgErrorMapperTest, ForeignKeyViolation_23503) {
EXPECT_EQ(mapPgSqlState("23503"), SqlError::Code::ForeignKeyViolation);
}
TEST(PgErrorMapperTest, UndefinedTable_42P01_IsNotFound) {
EXPECT_EQ(mapPgSqlState("42P01"), SqlError::Code::NotFound);
}
TEST(PgErrorMapperTest, QueryCanceled_57014_IsTimeout) {
EXPECT_EQ(mapPgSqlState("57014"), SqlError::Code::Timeout);
}
TEST(PgErrorMapperTest, ConnectionException_08xxx_IsConnectionLost) {
EXPECT_EQ(mapPgSqlState("08001"), SqlError::Code::ConnectionLost);
EXPECT_EQ(mapPgSqlState("08006"), SqlError::Code::ConnectionLost);
}
TEST(PgErrorMapperTest, Unknown_IsUnknown) {
EXPECT_EQ(mapPgSqlState("99999"), SqlError::Code::Unknown);
}
// ===========================================================================
// SqlTemplateGenerator — remaining uncovered branches
// ===========================================================================
/** Covers template_generator.hpp:97-98 — string field with default → 'value' */
TEST(SqlTemplateGeneratorTest, StringDefaultValue_Quoted) {
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
dbal::adapters::EntityDefinition e;
e.name = "Theme"; e.version = "1.0.0";
dbal::adapters::FieldDefinition f;
f.name = "color"; f.type = "string"; f.default_value = "light";
e.fields.push_back(f);
EXPECT_NO_THROW(gen.generateCreateTable(e, dbal::adapters::SqlDialect::PostgreSQL));
}
/** Covers template_generator.hpp:112-121 — non-uuid primary key auto-defaults */
TEST(SqlTemplateGeneratorTest, NonUuidPrimaryKey_AutoDefault_AllDialects) {
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
dbal::adapters::EntityDefinition e;
e.name = "Doc"; e.version = "1.0.0";
dbal::adapters::FieldDefinition f;
f.name = "id"; f.type = "cuid"; f.primary = true; // non-uuid primary key
e.fields.push_back(f);
EXPECT_NO_THROW(gen.generateCreateTable(e, dbal::adapters::SqlDialect::PostgreSQL));
EXPECT_NO_THROW(gen.generateCreateTable(e, dbal::adapters::SqlDialect::SQLite));
EXPECT_NO_THROW(gen.generateCreateTable(e, dbal::adapters::SqlDialect::MySQL));
}
/** Covers template_generator.hpp:167 — empty index fields skipped */
TEST(SqlTemplateGeneratorTest, EmptyIndexFields_Skipped) {
dbal::adapters::SqlTemplateGenerator gen(DBAL_TEST_TEMPLATE_DIR);
dbal::adapters::EntityDefinition e;
e.name = "Obj"; e.version = "1.0.0";
dbal::adapters::FieldDefinition f; f.name = "id"; f.type = "uuid"; f.primary = true;
e.fields.push_back(f);
dbal::adapters::IndexDefinition idx; // idx.fields is empty → skipped
e.indexes.push_back(idx);
auto indexes = gen.generateIndexes(e, dbal::adapters::SqlDialect::PostgreSQL);
EXPECT_TRUE(indexes.empty());
}