wallet, sqlite: Encapsulate SQLite statements in a RAII class #33033

pull achow101 wants to merge 9 commits into bitcoin:master from achow101:sqlite-stmt-raii changing 2 files +202 −207
  1. achow101 commented at 10:43 pm on July 21, 2025: member

    SQLite statements are C objects which can be long lived, so having them be initialized in a constructor, and cleanup everything in a destructor, makes the sqlite code cleaner and easier to read. This also lets us have the various functions needed to interact with sqlite statements be member functions.

    This will be particularly useful in future work which makes use of more complicated SQL statements.

  2. DrahtBot commented at 10:43 pm on July 21, 2025: contributor

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/33033.

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK vasild
    Concept ACK rkrux
    Stale ACK w0xlt

    If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update.

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #33960 (log: Use more severe log level (warn/err) where appropriate by maflcko)
    • #33034 (wallet: Store transactions in a separate sqlite table by achow101)
    • #29641 (scripted-diff: Use LogInfo over LogPrintf [WIP, NOMERGE, DRAFT] by maflcko)
    • #25722 (refactor: Use util::Result class for wallet loading by ryanofsky)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

  3. DrahtBot added the label CI failed on Jul 22, 2025
  4. DrahtBot commented at 2:33 am on July 22, 2025: contributor

    🚧 At least one of the CI tasks failed. Task previous releases, depends DEBUG: https://github.com/bitcoin/bitcoin/runs/46428794695 LLM reason (✨ experimental): The CI failure is caused by a static assertion failure in the sqlite.cpp file during compilation.

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

  5. achow101 force-pushed on Jul 22, 2025
  6. achow101 force-pushed on Jul 22, 2025
  7. achow101 force-pushed on Jul 22, 2025
  8. in src/wallet/sqlite.cpp:132 in 2c1edccfc5 outdated
    143+                static_cast<size_t>(sqlite3_column_bytes(m_stmt, col))
    144+            );
    145+        } else {
    146+            // GCC <= 12 and Clang <= 16 don't like static_assert(false) in an else, so we use the following to achieve the same effect
    147+            // From: https://devblogs.microsoft.com/oldnewthing/20200311-00/?p=103553
    148+            static_assert(!sizeof(T*));
    


    maflcko commented at 8:07 am on July 22, 2025:
    nit: you can use ALWAYS_FALSE for this, so that the workaround docs for the C++23 defect report are in one place only.

    achow101 commented at 5:01 pm on July 22, 2025:
    Done
  9. DrahtBot removed the label CI failed on Jul 22, 2025
  10. achow101 force-pushed on Jul 22, 2025
  11. in src/util/transaction_identifier.h:42 in 15d21068ce outdated
    35@@ -36,7 +36,10 @@ class transaction_identifier
    36     }
    37 
    38 public:
    39+    using value_type = uint256::value_type;
    40+
    41     transaction_identifier() : m_wrapped{} {}
    42+    transaction_identifier(std::span<const value_type> sp) : m_wrapped(sp) {}
    


    w0xlt commented at 9:00 pm on July 23, 2025:
    Is there any specific reason not to use unsigned char directly, like std::span<const unsigned char > sp) ?

    achow101 commented at 9:15 pm on July 23, 2025:
    In theory we could change uint256’s value_type, and we wouldn’t need to make any changes here in that case.

    maflcko commented at 7:58 am on July 24, 2025:

    I don’t understand this change. The commit message just says “util: Add value_type and span constructor to (W)Txid”, but it doesn’t say why or where this is needed. Compiling without this (locally) seems to pass.

    Also, conceptually, it seems to go in the opposite direction that we want to go in. This makes implicit construction from any byte span to transaction_identifier possible again. The constructor transaction_identifier(const uint256& wrapped) is private and will still catch those cases, but if this implementation detail is changed in the future, I presume direct and implicit construction from uint256 will be possible again.

    I think it would be good to explain this change a bit better for reviewers.


    achow101 commented at 5:40 pm on July 24, 2025:

    In SQLiteStatement::Column<T>(), one of the ways we construct the appropriate data from a blob is to do reinterpret_cast<T::value_type>. Txid met all of the requirements for using that constructor, except that it didn’t have a value_type, hence this commit adding it.

    But it looks like I didn’t include that commit in this PR. Rather, it’s https://github.com/bitcoin/bitcoin/pull/33034/commits/afe99b3b7df6c658fb4068c6418dfd7ebf151460 in #33034.


    maflcko commented at 7:50 pm on July 24, 2025:

    I see. Though, it may still be good to make this one explicit to avoid silent uint256 conversions in the future?

    Also, Txid::data is std::byte*, so value_type should probably be std::byte as well.

    I haven’t tried, but I presume both modifications should compile with afe99b3b7df6c658fb4068c6418dfd7ebf151460


    achow101 commented at 8:49 pm on July 24, 2025:
    I’ve dropped this commit from this PR and moved it to #33034. Also dropped value_type and made the constructor just take span<const std::byte> and that seems to work.
  12. w0xlt commented at 11:03 pm on July 23, 2025: contributor
  13. in src/wallet/sqlite.cpp:61 in b2c824a3ce outdated
    70-        sqlite3_reset(stmt);
    71-        return false;
    72+    T(data, size);
    73+};
    74+
    75+/** RAII class that encapsualtes a sqlite3_stmt */
    


    maflcko commented at 7:13 am on July 24, 2025:
    encapsualtes -> encapsulates [spelling error in comment “encapsualtes” makes the word misspelled]
    

    achow101 commented at 8:50 pm on July 24, 2025:
    Done
  14. achow101 force-pushed on Jul 24, 2025
  15. achow101 force-pushed on Jul 24, 2025
  16. achow101 requested review from theStack on Oct 22, 2025
  17. achow101 requested review from vasild on Oct 22, 2025
  18. achow101 requested review from Eunovo on Oct 22, 2025
  19. in src/wallet/sqlite.h:15 in 1829b3512d


    rkrux commented at 1:23 pm on October 31, 2025:

    In d13ffed94ad5f5d488f8d72e4d5d7f1c3fb27c2b “sqlite: Have SQLiteCursor store SQLiteStatement”

    It doesn’t seem to be used anymore here; builds and tests fine without it.

    0--- a/src/wallet/sqlite.h
    1+++ b/src/wallet/sqlite.h
    2@@ -12,7 +12,6 @@
    3
    4struct bilingual_str;
    5
    6-struct sqlite3_stmt;
    7struct sqlite3;
    8
    9namespace wallet {
    

    achow101 commented at 9:26 pm on November 17, 2025:
    Done
  20. rkrux commented at 2:22 pm on October 31, 2025: contributor

    Started reviewing; I’m not opposed to this introduction at the moment and I will get more clarity soon in the review.

    Initial Concept ACK 1829b3512d7bf430ec44daa801eb8fadf46cd30b

    These 3 commits from #33034 looks pretty generic and related enough. Can they be included in this PR?

    • 818989093d9e6b8ee765c21630f7342b99af7fa0 “sqlite: Make SQLiteStatement::Bind a template function”
    • 07cac74e1340c7e771850633dd9418ff3efa5bb7 “sqlite: Make SQLiteDatabase::Column an std::optional”
    • ff47eea527e58071bdaff5c2acbc98ee97294021 “sqlite: Add additional blob types to SQLiteStatement::Column”
  21. in src/wallet/sqlite.cpp:65 in 1829b3512d
    74+
    75+/** RAII class that encapsulates a sqlite3_stmt */
    76+class SQLiteStatement
    77+{
    78+private:
    79+    sqlite3* m_db{nullptr};
    


    vasild commented at 10:04 am on November 12, 2025:
    Looks like m_db can never be nullptr since it is initialized by the constructor to a presumably non-null value. Consider making this a reference sqlite3& m_db;. That would signal to the readers that it can never be null and will also make it impossible to have weird states of this class that somehow end up having a null member.

    achow101 commented at 9:26 pm on November 17, 2025:
    Done
  22. in src/wallet/sqlite.cpp:74 in 1829b3512d
    83+    explicit SQLiteStatement(sqlite3* db, const std::string& stmt_text)
    84+        : m_db(db)
    85+    {
    86+        int res = sqlite3_prepare_v2(m_db, stmt_text.c_str(), -1, &m_stmt, nullptr);
    87+        if (res != SQLITE_OK) {
    88+            throw std::runtime_error(strprintf(
    


    vasild commented at 10:12 am on November 12, 2025:

    Just an observation:

    Some of the callers of sqlite3_prepare_v2() call sqlite3_finalize() on failure. The doc says that if prepare fails then *ppStmt is set to NULL and Invoking sqlite3_finalize() on a NULL pointer is a harmless no-op.

    So it is ok to omit the finalize call.

  23. in src/wallet/sqlite.cpp:75 in 1829b3512d
    84+        : m_db(db)
    85+    {
    86+        int res = sqlite3_prepare_v2(m_db, stmt_text.c_str(), -1, &m_stmt, nullptr);
    87+        if (res != SQLITE_OK) {
    88+            throw std::runtime_error(strprintf(
    89+                "SQLiteDatabase: Failed to prepare SQL statement: %s\n", sqlite3_errstr(res)));
    


    vasild commented at 1:17 pm on November 12, 2025:
    The class name is SQLiteStatement, is this intentionally SQLiteDatabase?

    vasild commented at 1:18 pm on November 12, 2025:
    I think it would be useful to print the statement as well (stmt_text).

    achow101 commented at 9:26 pm on November 17, 2025:
    Done

    achow101 commented at 9:26 pm on November 17, 2025:
    Done
  24. in src/wallet/sqlite.cpp:82 in 1829b3512d outdated
    94-}
    95+    ~SQLiteStatement()
    96+    {
    97+        Reset();
    98+        sqlite3_finalize(m_stmt);
    99+    }
    


    vasild commented at 1:24 pm on November 12, 2025:
    Reset() is not needed here?

    achow101 commented at 9:26 pm on November 17, 2025:
    Removed
  25. in src/wallet/sqlite.cpp:116 in 1829b3512d outdated
    127+
    128+    template<typename T>
    129+    T Column(int col)
    130+    {
    131+        if constexpr (std::integral<T> && sizeof(T) <= 4) {
    132+            return sqlite3_column_int(m_stmt, col);
    


    vasild commented at 1:35 pm on November 12, 2025:

    From https://sqlite.org/c3ref/column_blob.html:

    if the column index is out of range, the result is undefined

    Maybe at the beginning of the function, add:

    0Assert(col < sqlite3_column_count());
    

    achow101 commented at 9:26 pm on November 17, 2025:
    Done
  26. in src/wallet/sqlite.cpp:118 in 1829b3512d outdated
    129+    T Column(int col)
    130+    {
    131+        if constexpr (std::integral<T> && sizeof(T) <= 4) {
    132+            return sqlite3_column_int(m_stmt, col);
    133+        } else if constexpr (std::integral<T> && sizeof(T) <= 8) {
    134+            return static_cast<int64_t>(sqlite3_column_int64(m_stmt, col));
    


    vasild commented at 1:52 pm on November 12, 2025:
    What happens if T is unsigned? Maybe add https://en.cppreference.com/w/cpp/types/is_signed.html here: std::integral<T> && std::is_signed<T>? (and leave it to drop to “else assert always false” for unsigned Ts)

    achow101 commented at 9:27 pm on November 17, 2025:
    Done
  27. in src/wallet/sqlite.cpp:138 in 1829b3512d
    157-        sqlite3_finalize(pragma_read_stmt);
    158-        error = Untranslated(strprintf("SQLiteDatabase: Failed to prepare the statement to fetch %s: %s", description, sqlite3_errstr(ret)));
    159-        return std::nullopt;
    160-    }
    161-    ret = sqlite3_step(pragma_read_stmt);
    162+    SQLiteStatement pragma_read_stmt(db, stmt_text);
    


    vasild commented at 2:14 pm on November 12, 2025:
    Previously on error ReadPragmaInteger() would have returned nullopt. Now it would throw. Its only caller is SQLiteDatabase::Verify() which would have returned false and now would let the exception propagate. The only caller of SQLiteDatabase::Verify() is MakeSQLiteDatabase(). Before, on Verify() failure (return false) MakeSQLiteDatabase() would have set status = DatabaseStatus::FAILED_VERIFY and now (exception) it would set status = DatabaseStatus::FAILED_LOAD. Is this ok?

    achow101 commented at 9:27 pm on November 17, 2025:
    I’ve changed this to catch the exception and return std::nullopt.
  28. in src/wallet/sqlite.cpp:143 in 1829b3512d
    163+    int ret = pragma_read_stmt.Step();
    164     if (ret != SQLITE_ROW) {
    165-        sqlite3_finalize(pragma_read_stmt);
    166         error = Untranslated(strprintf("SQLiteDatabase: Failed to fetch %s: %s", description, sqlite3_errstr(ret)));
    167         return std::nullopt;
    168     }
    


    vasild commented at 2:21 pm on November 12, 2025:

    Now ReadPragmaInteger() would return nullopt on some errors and throw on others. What about making it to only throw? Then its return type would change from std::optional<int> to int and its callers would not need to check if (!read_result.has_value()) ....

    I guess also SQLiteStatement::Step() can throw if the return value of sqlite3_step() is not SQLITE_ROW or SQLITE_DONE.


    achow101 commented at 9:27 pm on November 17, 2025:
    Changed to catch the exception.
  29. in src/wallet/sqlite.cpp:126 in 1829b3512d outdated
    134+            return static_cast<int64_t>(sqlite3_column_int64(m_stmt, col));
    135+        } else if constexpr (std::is_same_v<T, std::string>) {
    136+            const char* text = (const char*)sqlite3_column_text(m_stmt, col);
    137+            size_t size = sqlite3_column_bytes(m_stmt, col);
    138+            std::string str_text(text, size);
    139+            return str_text;
    


    vasild commented at 2:43 pm on November 12, 2025:

    sqlite3_column_text() can return nullptr, better check that. The previous caller did check:

    0-        const char* msg = (const char*)sqlite3_column_text(stmt, 0);
    1-        if (!msg) {                                                                                               
    2-            error = ...
    

    achow101 commented at 9:28 pm on November 17, 2025:

    Done, returns an empty string now.

    But the only time sqlite3_column_text should return NULL is if the column contains a NULL value. Empty strings are not stored as NULL.

  30. in src/wallet/sqlite.cpp:591 in 1829b3512d
    595-    int res = sqlite3_finalize(m_cursor_stmt);
    596-    if (res != SQLITE_OK) {
    597-        LogPrintf("%s: cursor closed but could not finalize cursor statement: %s\n",
    598-                  __func__, sqlite3_errstr(res));
    599+    m_cursor_stmt = std::make_unique<SQLiteStatement>(db, stmt_text);
    600+}
    


    vasild commented at 2:54 pm on November 12, 2025:

    nit, here and in the other constructor:

    0SQLiteCursor::SQLiteCursor(sqlite3* db, const std::string& stmt_text)
    1    : m_cursor_stmt(std::make_unique<SQLiteStatement>(db, stmt_text)) {}
    

    achow101 commented at 9:28 pm on November 17, 2025:
    Done
  31. in src/wallet/sqlite.cpp:457 in 1829b3512d
    458-                      stmt_description, sqlite3_errstr(res));
    459-        }
    460-        *stmt_prepared = nullptr;
    461+        // Reset the unique_ptr to destroy the contained SQLiteStatement
    462+        stmt_prepared->reset();
    463     }
    


    vasild commented at 3:18 pm on November 12, 2025:

    The second element in the pair (stmt_description) is not used. This snippet can be reduced to:

    0    for (auto stmt : {&m_read_stmt, &m_insert_stmt, &m_overwrite_stmt, &m_delete_stmt, &m_delete_prefix_stmt}) {
    1        stmt->reset();
    2    }                   
    

    Or simple stupid:

    0    m_read_stmt.reset();
    1    m_insert_stmt.reset();
    2    m_overwrite_stmt.reset();
    3    m_delete_stmt.reset();
    4    m_delete_prefix_stmt.reset();
    

    achow101 commented at 9:28 pm on November 17, 2025:
    Done
  32. in src/wallet/sqlite.cpp:81 in 1829b3512d outdated
    93-    return true;
    94-}
    95+    ~SQLiteStatement()
    96+    {
    97+        Reset();
    98+        sqlite3_finalize(m_stmt);
    


    vasild commented at 3:28 pm on November 12, 2025:

    Some of the callers of sqlite3_finalize() used to check its return value. Here in the destructor it is too late to signal an error to the caller.

    From: https://sqlite.org/c3ref/finalize.html

    If the most recent evaluation of statement S failed, then sqlite3_finalize(S) returns the appropriate error code or extended error code

    I am not sure what is the best way to handle this. Ignoring the return value does not look good.

    Maybe assert that sqlite3_finalize() returns SQLITE_OK? And also somehow make sure that if “the most recent evaluation of statement S failed”, then this is handled already, before the destructor is called. That would mean if sqlite3_step(), called from Step() fails (result is not SQLITE_ROW or SQLITE_DONE) then to call sqlite3_finalize() from inside Step()?


    vasild commented at 6:02 am on November 14, 2025:

    Maybe (not tested):

    0    int Step()
    1    {
    2        const int ret = sqlite3_step(m_stmt);
    3        if (ret == SQLITE_ROW || ret == SQLITE_DONE) {
    4            return ret;
    5        }
    6        sqlite3_finalize(m_stmt);
    7        m_stmt = nullptr;
    8        // then the destructor can assert(sqlite3_finalize(m_stmt) == SQLITE_OK);
    9    }
    

    Related to #33033 (review)


    achow101 commented at 9:11 pm on November 17, 2025:

    If the most recent evaluation of statement S failed, then sqlite3_finalize(S) returns the appropriate error code or extended error code

    I am not sure what is the best way to handle this. Ignoring the return value does not look good.

    This text reads to me as sqlite3_finalize propagating any previous errors, e.g. from sqlite3_step. Since any errors from that should already be handled, I think it’s ok to ignore the result of finalize. I think the previous code is actually incorrect since the error result is not actually a result of finalize failing.


    vasild commented at 12:38 pm on November 20, 2025:

    Yeah, I was thinking the same. It is just super odd to ignore database errors (in general). I guess the sqlite API is odd here.

    Edit: if you retouch, maybe change int Step() to [[nodiscard]] int Step() to hint the callers to not ignore the return value.

  33. vasild commented at 4:21 pm on November 12, 2025: contributor
    Approach ACK 1829b3512d7bf430ec44daa801eb8fadf46cd30b
  34. sqlite: Add SQLiteStatement RAII class
    This class will be used to encapsulate a sqlite3_stmt
    d9f7f51614
  35. sqlite: Make Column template function ecd978c379
  36. sqlite: Refactor ReadPragmaInteger to use SQLiteStatement b331f2824f
  37. sqlite: Use SQLiteStatement in PRAGMA integrity_check b8475186a2
  38. sqlite: Use SQLiteStatement in check_main_stmt 2f7a6c58a2
  39. sqlite: Have SQLiteCursor store SQLiteStatement 7a7d045133
  40. sqlite: Replace remaining sqlite3_stmt usage with SQLiteStatement f761119e6f
  41. sqlite: Refactor common writing code from WriteKey and ExecStatement
    WriteKey and ExecStatement use the same code for the actual execution of
    the statement. This is refactored into a separate function, also called
    ExecStatement, and the original ExecStatement renamed to
    ExecEraseStatement as it is only used by the erase functions.
    6f8954f136
  42. sqlite: Inline BindBlobToStatement and SpanFromBlob 0af7c2a118
  43. achow101 force-pushed on Nov 17, 2025
  44. vasild approved
  45. vasild commented at 12:23 pm on November 20, 2025: contributor
    ACK 0af7c2a11819a21e11abf34b4229c5c21150d827
  46. DrahtBot requested review from rkrux on Nov 20, 2025
  47. DrahtBot requested review from w0xlt on Nov 20, 2025
  48. DrahtBot added the label Needs rebase on Dec 2, 2025
  49. DrahtBot commented at 2:56 pm on December 2, 2025: contributor
    🐙 This pull request conflicts with the target branch and needs rebase.

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2025-12-02 21:13 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me