Skip to content

Database testing #54

Description

@bHimes

Add Test Coverage for Database Subsystem

Summary

The database subsystem (database.cpp, database.h, database_schema.h) currently has 0% test coverage despite a 60% bug-fix rate in commits over the past year. This issue tracks the top 5 test priorities identified through git history analysis.

Key Metrics:

  • 3,348 lines of custom database code
  • Only 1 basic schema test exists in console_test
  • 60-63% of database commits are bug fixes (31+ bugs in past year)
  • 720+ database method calls across 44 GUI files

Priority 0: SQL Query Construction & Column Validation

Risk Score: 90/100 (highest)
Bug Prevention: Would have prevented 8+ bugs in past year

Issues Identified

Recent bugs from git history:

  • Commit 7b7177b: Wrong column name (SEARCH_ID vs JOB_ID) caused query to fail silently
  • Commit e6a66bc: SQL INSERT with wrong number of values ("30 values for 31 columns")
  • Commit 996ed668: Type mismatch (numpy.int64 vs Python int) returned 0 rows
  • Commit 44a4e99: Dynamic table name with 0 suffix: IMAGE_GROUP_0 (invalid - should start at 1)

Root Causes

  1. Dynamic table naming pattern - Tables like TEMPLATE_MATCH_PEAK_LIST_{id} constructed at runtime
  2. Schema complexity - 42 table types with 275+ unique columns
  3. Variadic format strings - Type conversion happens at runtime, no compile-time validation
  4. Terminology inconsistencies - Schema evolution (JOB → SEARCH) broke existing queries

Example Tests

TEST_CASE("Database::DynamicTableNameGeneration") {
    Database db;
    db.CreateNewDatabase("test.db");

    SECTION("1-based indexing for dynamic tables") {
        // Valid: IDs start at 1
        db.CreateImageGroupTable(1);
        REQUIRE(db.DoesTableExist("IMAGE_GROUP_1"));

        // Invalid: 0-based naming should fail
        REQUIRE_THROWS(db.CreateImageGroupTable(0));
    }

    SECTION("Template match peak list naming") {
        auto table_name = db.GetTemplateMatchPeakListTable(123);
        REQUIRE(table_name == "TEMPLATE_MATCH_PEAK_LIST_123");
    }
}

TEST_CASE("Database::ColumnValidation") {
    Database db;
    db.CreateNewDatabase("test.db");

    SECTION("Detect wrong column names in queries") {
        // Should fail when using wrong terminology
        REQUIRE_THROWS(db.ExecuteSQL("SELECT JOB_ID FROM TEMPLATE_MATCH_LIST"));
        REQUIRE_NOTHROW(db.ExecuteSQL("SELECT SEARCH_ID FROM TEMPLATE_MATCH_LIST"));
    }

    SECTION("Column count mismatch in INSERT") {
        // Validate column count matches value count
        db.InsertOrReplace("IMAGE_ASSETS", "pttiii",
            "IMAGE_ASSET_ID", "NAME", "FILENAME", "X", "Y", "Z",
            1, "test", "test.mrc", 100, 200);  // 5 values but format has 6 columns
        // Should detect mismatch and fail
    }
}

TEST_CASE("Database::TypeConversion") {
    Database db;
    db.CreateNewDatabase("test.db");

    SECTION("Handle different integer types") {
        long id = 12345L;
        int position = 100;

        db.InsertOrReplace("IMAGE_ASSETS", "pi",
            "IMAGE_ASSET_ID", "POSITION",
            id, position);

        // Retrieve with correct types
        long retrieved_id = db.ReturnSingleLongFromSelectCommand("SELECT IMAGE_ASSET_ID FROM IMAGE_ASSETS");
        REQUIRE(retrieved_id == id);
    }
}

Priority 1: Database ID Validation & Lifecycle

Risk Score: 80/100
Bug Prevention: Would have prevented 6+ bugs in past year

Issues Identified

Recent bugs from git history:

  • Commit 5195135: 4 locations using >= 0 instead of > 0 for ID validation (SQLite IDs start at 1)
  • Commit 44a4e99: 0-based ComboBox selection vs 1-based database ID: tried to access IMAGE_GROUP_0 (invalid)
  • Commit 40bee19: "Critical queue bug" - confusion between database IDs and array indices

Root Causes

  1. Semantic mismatch - GUI uses 0-based indexing (ComboBox, arrays), database uses 1-based IDs
  2. Special values - -1 means "not assigned", 0 is invalid, > 0 is valid
  3. Empty table behavior - ReturnHighest*ID() returns 0 for empty tables
  4. No centralized validation - ID checks scattered across codebase

Example Tests

TEST_CASE("Database::IDValidation") {
    Database db;
    db.CreateNewDatabase("test.db");

    SECTION("Valid ID ranges") {
        REQUIRE(db.IsValidDatabaseID(1) == true);
        REQUIRE(db.IsValidDatabaseID(100) == true);
        REQUIRE(db.IsValidDatabaseID(0) == false);   // 0 is invalid
        REQUIRE(db.IsValidDatabaseID(-1) == false);  // -1 means "unassigned"
    }

    SECTION("Empty table highest ID returns 0") {
        int highest = db.ReturnHighestImageAssetID();
        REQUIRE(highest == 0);

        // Next ID should be 1, not 0
        int next_id = highest + 1;
        REQUIRE(next_id == 1);
    }

    SECTION("After insertion, highest ID is valid") {
        db.InsertOrReplace("IMAGE_ASSETS", "pt",
            "IMAGE_ASSET_ID", "NAME",
            1, "test");

        int highest = db.ReturnHighestImageAssetID();
        REQUIRE(highest == 1);
        REQUIRE(db.IsValidDatabaseID(highest) == true);
    }
}

TEST_CASE("Database::IndexConversion") {
    SECTION("ComboBox selection to database ID") {
        int combo_selection = 0;  // First item in ComboBox (0-based)
        int db_id = combo_selection + 1;  // Convert to 1-based
        REQUIRE(db_id == 1);

        // Not: db_id = combo_selection (wrong - gives 0, which is invalid)
    }

    SECTION("Array index to database ID") {
        std::vector<int> db_ids = {5, 10, 15};
        int array_index = 0;  // First element
        int db_id = db_ids[array_index];  // Get actual ID
        REQUIRE(db_id == 5);  // Not 0!

        // Mistake: using array_index directly as db_id
        REQUIRE(array_index != db_id);
    }
}

Priority 2: Transaction Management & Exception Safety

Risk Score: 70/100
Bug Prevention: Prevents data corruption

Issues Identified

Recent bugs from git history:

  • Commit f154a9b: "Fix queue manager state corruption in LoadQueueFromDatabase"
  • Production logs: "Transaction number (N) is not 0 upon close!" warnings

Root Causes

  1. Manual transaction counter - number_of_active_transactions can leak or become negative
  2. No rollback support - Despite BEGIN/COMMIT, no Rollback() method exists
  3. Exception safety - Counter inconsistency possible on exceptions
  4. Nested transactions - Complex semantics with single SQLite transaction

Current Implementation

inline void Begin() {
    if (number_of_active_transactions == 0)
        ExecuteSQL("BEGIN IMMEDIATE;");
    number_of_active_transactions++;
}

inline void Commit() {
    number_of_active_transactions--;
    if (number_of_active_transactions == 0)
        ExecuteSQL("COMMIT;");
}

Example Tests

TEST_CASE("Database::TransactionManagement") {
    Database db;
    db.CreateNewDatabase("test.db");

    SECTION("Nested transaction counting") {
        REQUIRE(db.GetActiveTransactionCount() == 0);

        db.Begin();
        REQUIRE(db.GetActiveTransactionCount() == 1);

        db.Begin();  // Nested
        REQUIRE(db.GetActiveTransactionCount() == 2);

        db.Commit();
        REQUIRE(db.GetActiveTransactionCount() == 1);

        db.Commit();
        REQUIRE(db.GetActiveTransactionCount() == 0);
    }

    SECTION("BeginCommitLocker RAII") {
        REQUIRE(db.GetActiveTransactionCount() == 0);

        {
            BeginCommitLocker locker(&db);
            REQUIRE(db.GetActiveTransactionCount() == 1);

            // Locker destructor should commit
        }

        REQUIRE(db.GetActiveTransactionCount() == 0);
    }

    SECTION("Exception safety with RAII") {
        REQUIRE(db.GetActiveTransactionCount() == 0);

        try {
            BeginCommitLocker locker(&db);
            REQUIRE(db.GetActiveTransactionCount() == 1);
            throw std::runtime_error("Simulated error");
        } catch (...) {
            // Locker destructor should still clean up
        }

        REQUIRE(db.GetActiveTransactionCount() == 0);
    }

    SECTION("Transaction leak detection on close") {
        db.Begin();
        db.Begin();
        // Don't commit - simulate leak

        // Close should detect leak and warn or error
        testing::internal::CaptureStderr();
        db.Close();
        std::string output = testing::internal::GetCapturedStderr();
        REQUIRE(output.find("Transaction number") != std::string::npos);
    }

    SECTION("Manual commit in RAII locker") {
        BeginCommitLocker locker(&db);
        REQUIRE(db.GetActiveTransactionCount() == 1);

        locker.Commit();  // Early manual commit
        REQUIRE(db.GetActiveTransactionCount() == 0);

        // Destructor should not commit again
    }
}

TEST_CASE("Database::RollbackSupport") {
    Database db;
    db.CreateNewDatabase("test.db");

    SECTION("Rollback functionality missing") {
        // Current implementation has no Rollback() method
        // Should this be added?

        db.Begin();
        db.InsertOrReplace("IMAGE_ASSETS", "pt",
            "IMAGE_ASSET_ID", "NAME",
            1, "test");

        // If we had Rollback():
        // db.Rollback();
        // REQUIRE(db.ReturnNumberOfImageAssets() == 0);

        // Currently: must Commit() or leak transaction
        db.Commit();
    }
}

Priority 3: Schema Migration & CheckSchema Performance

Risk Score: 65/100
Bug Prevention: Prevents migration failures and performance degradation

Issues Identified

Recent bugs from git history:

  • Commit a02dbcd: "Extreme slowdown in CheckSchema" - took 10+ minutes with many template match results
  • Required #ifdef SKIP_TM_TABLE_CHECK workaround

Root Causes

  1. O(n²) performance - CheckSchema queries sqlite_master for each dynamic table instance
  2. Table proliferation - Each template match job creates new tables: TEMPLATE_MATCH_PEAK_LIST_1, _2, _3...
  3. No caching - Repeated queries to sqlite_master
  4. Limited test coverage - Only 1 basic schema test exists

Example Tests

TEST_CASE("Database::SchemaMigration") {
    Database db;

    SECTION("Detect missing columns") {
        db.CreateNewDatabase("test.db");

        // Simulate old schema missing ICINESS column
        db.ExecuteSQL("CREATE TABLE ESTIMATED_CTF_PARAMETERS (DEFOCUS1 REAL)");

        auto [missing_tables, missing_columns] = db.CheckSchema();

        // Should detect missing ICINESS column
        bool found_iciness = false;
        for (const auto& col : missing_columns) {
            if (col.table == "ESTIMATED_CTF_PARAMETERS" && col.column == "ICINESS") {
                found_iciness = true;
            }
        }
        REQUIRE(found_iciness);
    }

    SECTION("Data preservation during migration") {
        db.CreateNewDatabase("test.db");

        // Old schema
        db.CreateTable("IMAGE_ASSETS", "pt", "IMAGE_ASSET_ID", "NAME");
        db.InsertOrReplace("IMAGE_ASSETS", "pt", "IMAGE_ASSET_ID", "NAME", 1, "test");

        // Add new column
        db.AddColumnToTable("IMAGE_ASSETS", "FILENAME", "TEXT");

        // Verify existing data preserved
        wxString name = db.ReturnSingleStringFromSelectCommand(
            "SELECT NAME FROM IMAGE_ASSETS WHERE IMAGE_ASSET_ID=1");
        REQUIRE(name == "test");

        // New column should be NULL
        wxString filename = db.ReturnSingleStringFromSelectCommand(
            "SELECT FILENAME FROM IMAGE_ASSETS WHERE IMAGE_ASSET_ID=1");
        REQUIRE(filename == "");  // NULL as wxString
    }

    SECTION("CheckSchema performance with many tables") {
        db.CreateNewDatabase("test.db");

        // Create many dynamic tables
        for (int i = 1; i <= 100; i++) {
            db.CreateTemplateMatchPeakListTable(i);
        }

        auto start = std::chrono::high_resolution_clock::now();
        auto [missing_tables, missing_columns] = db.CheckSchema();
        auto end = std::chrono::high_resolution_clock::now();

        auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);

        // Should complete in reasonable time (not O(n²))
        REQUIRE(duration.count() < 1000);  // Less than 1 second for 100 tables
    }
}

TEST_CASE("Database::TypeMapping") {
    Database db;
    db.CreateNewDatabase("test.db");

    SECTION("Format string to SQLite type mapping") {
        // 'p' = INTEGER PRIMARY KEY
        // 't' = TEXT
        // 'r' = REAL
        // 'i' = INTEGER

        db.CreateTable("TEST_TABLE", "ptri",
            "ID", "NAME", "VALUE", "COUNT");

        // Verify schema
        auto schema = db.GetTableSchema("TEST_TABLE");
        REQUIRE(schema["ID"] == "INTEGER PRIMARY KEY");
        REQUIRE(schema["NAME"] == "TEXT");
        REQUIRE(schema["VALUE"] == "REAL");
        REQUIRE(schema["COUNT"] == "INTEGER");
    }
}

Priority 4: Batch Insert/Select Operations

Risk Score: 63/100
Bug Prevention: Prevents data loss and memory leaks

Issues Identified

Historical bugs:

  • Statement finalization memory leaks
  • Vector access without size updates
  • Column count mismatches between BeginBatchInsert and AddToBatchInsert

Root Causes

  1. Manual statement management - sqlite3_stmt* must be finalized on all code paths
  2. Stateful API - Begin/Add/End pairing required, but not enforced
  3. Format string matching - No validation between Begin format and Add format

Example Tests

TEST_CASE("Database::BatchInsert") {
    Database db;
    db.CreateNewDatabase("test.db");

    SECTION("Begin/Add/End pairing") {
        db.BeginBatchInsert("IMAGE_ASSETS", 2, "IMAGE_ASSET_ID", "NAME");

        for (int i = 1; i <= 10; i++) {
            db.AddToBatchInsert("pt", i, wxString::Format("image_%d", i));
        }

        db.EndBatchInsert();

        // Verify all inserted
        int count = db.ReturnNumberOfImageAssets();
        REQUIRE(count == 10);
    }

    SECTION("Column count consistency") {
        db.BeginBatchInsert("IMAGE_ASSETS", 2, "IMAGE_ASSET_ID", "NAME");

        // Mismatch: 3 values but only 2 columns
        REQUIRE_THROWS(db.AddToBatchInsert("ptt", 1, "name", "extra"));

        db.EndBatchInsert();
    }

    SECTION("Large batch performance") {
        auto start = std::chrono::high_resolution_clock::now();

        db.BeginBatchInsert("IMAGE_ASSETS", 2, "IMAGE_ASSET_ID", "NAME");
        for (int i = 1; i <= 10000; i++) {
            db.AddToBatchInsert("pt", i, wxString::Format("image_%d", i));
        }
        db.EndBatchInsert();

        auto end = std::chrono::high_resolution_clock::now();
        auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);

        // Should be fast (100-1000x faster than individual inserts)
        REQUIRE(duration.count() < 1000);  // Less than 1 second for 10k rows

        // Verify count
        REQUIRE(db.ReturnNumberOfImageAssets() == 10000);
    }

    SECTION("End without Begin should fail") {
        REQUIRE_THROWS(db.EndBatchInsert());
    }
}

TEST_CASE("Database::BatchSelect") {
    Database db;
    db.CreateNewDatabase("test.db");

    // Populate test data
    db.BeginBatchInsert("IMAGE_ASSETS", 2, "IMAGE_ASSET_ID", "NAME");
    for (int i = 1; i <= 5; i++) {
        db.AddToBatchInsert("pt", i, wxString::Format("image_%d", i));
    }
    db.EndBatchInsert();

    SECTION("Iterate through results") {
        db.BeginBatchSelect("SELECT IMAGE_ASSET_ID, NAME FROM IMAGE_ASSETS ORDER BY IMAGE_ASSET_ID");

        int count = 0;
        int id;
        wxString name;
        while (db.GetFromBatchSelect("pt", &id, &name)) {
            count++;
            REQUIRE(id == count);
            REQUIRE(name == wxString::Format("image_%d", count));
        }

        db.EndBatchSelect();
        REQUIRE(count == 5);
    }

    SECTION("Empty result set") {
        db.BeginBatchSelect("SELECT IMAGE_ASSET_ID, NAME FROM IMAGE_ASSETS WHERE IMAGE_ASSET_ID > 100");

        int id;
        wxString name;
        bool found = db.GetFromBatchSelect("pt", &id, &name);

        REQUIRE(found == false);
        db.EndBatchSelect();
    }
}

Priority 5: Asset Management CRUD Operations

Risk Score: 45/100
Bug Prevention: Ensures basic operations work correctly

Issues Identified

  • No tests for basic INSERT/UPDATE/DELETE operations
  • InsertOrReplace semantics not verified
  • Primary key conflict handling not tested
  • Cascade deletion behavior undefined

Example Tests

TEST_CASE("Database::AssetCRUD") {
    Database db;
    db.CreateNewDatabase("test.db");

    SECTION("Insert new asset") {
        db.InsertOrReplace("IMAGE_ASSETS", "pt",
            "IMAGE_ASSET_ID", "NAME",
            1, "test_image");

        int count = db.ReturnNumberOfImageAssets();
        REQUIRE(count == 1);

        wxString name = db.ReturnSingleStringFromSelectCommand(
            "SELECT NAME FROM IMAGE_ASSETS WHERE IMAGE_ASSET_ID=1");
        REQUIRE(name == "test_image");
    }

    SECTION("Update existing asset with InsertOrReplace") {
        // Insert
        db.InsertOrReplace("IMAGE_ASSETS", "pt",
            "IMAGE_ASSET_ID", "NAME",
            1, "original");

        // Replace (update)
        db.InsertOrReplace("IMAGE_ASSETS", "pt",
            "IMAGE_ASSET_ID", "NAME",
            1, "updated");

        // Should have 1 row, not 2
        int count = db.ReturnNumberOfImageAssets();
        REQUIRE(count == 1);

        wxString name = db.ReturnSingleStringFromSelectCommand(
            "SELECT NAME FROM IMAGE_ASSETS WHERE IMAGE_ASSET_ID=1");
        REQUIRE(name == "updated");
    }

    SECTION("Delete asset") {
        db.InsertOrReplace("IMAGE_ASSETS", "pt",
            "IMAGE_ASSET_ID", "NAME",
            1, "test");

        REQUIRE(db.ReturnNumberOfImageAssets() == 1);

        db.ExecuteSQL("DELETE FROM IMAGE_ASSETS WHERE IMAGE_ASSET_ID=1");

        REQUIRE(db.ReturnNumberOfImageAssets() == 0);
    }

    SECTION("Query non-existent ID") {
        wxString name = db.ReturnSingleStringFromSelectCommand(
            "SELECT NAME FROM IMAGE_ASSETS WHERE IMAGE_ASSET_ID=999");

        // Should return empty string for no results
        REQUIRE(name == "");
    }
}

Implementation Notes

Test Framework

Use Catch2 (already in project) for unit tests.

Test Location

Suggested structure:

src/test/
  database/
    test_queries.cpp          (Priority 0)
    test_ids.cpp              (Priority 1)
    test_transactions.cpp     (Priority 2)
    test_schema.cpp           (Priority 3)
    test_batch_ops.cpp        (Priority 4)
    test_crud.cpp             (Priority 5)

Test Fixtures

Create reusable fixtures:

class DatabaseTestFixture {
protected:
    Database db;
    wxString test_db_path;

    void SetUp() {
        test_db_path = wxFileName::CreateTempFileName("cistem_test_");
        db.CreateNewDatabase(test_db_path);
    }

    void TearDown() {
        db.Close();
        wxRemoveFile(test_db_path);
    }
};

Mock/Stub Considerations

  • wxString already available in test environment
  • No need to mock SQLite - use real database for integration tests
  • Consider mocking for file I/O tests (CreateNewDatabase, CopyDatabaseFile)

Related Issues

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions