From bbc3233cce5e0d2f8bb1407d799a0bcc60aadbbd Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 19 May 2026 15:23:58 +0200 Subject: [PATCH 1/4] Initial support for sqlite3 --- .../yup_core/database/yup_SqliteDatabase.cpp | 544 +++++++ .../yup_core/database/yup_SqliteDatabase.h | 416 +++++ modules/yup_core/yup_core.cpp | 5 + modules/yup_core/yup_core.h | 5 + tests/CMakeLists.txt | 3 +- tests/yup_core/yup_SqliteDatabase.cpp | 1355 +++++++++++++++++ thirdparty/sqlite3_library/sqlite3_library.c | 31 + thirdparty/sqlite3_library/sqlite3_library.h | 45 + 8 files changed, 2403 insertions(+), 1 deletion(-) create mode 100644 modules/yup_core/database/yup_SqliteDatabase.cpp create mode 100644 modules/yup_core/database/yup_SqliteDatabase.h create mode 100644 tests/yup_core/yup_SqliteDatabase.cpp create mode 100644 thirdparty/sqlite3_library/sqlite3_library.c create mode 100644 thirdparty/sqlite3_library/sqlite3_library.h diff --git a/modules/yup_core/database/yup_SqliteDatabase.cpp b/modules/yup_core/database/yup_SqliteDatabase.cpp new file mode 100644 index 000000000..47a9ebd52 --- /dev/null +++ b/modules/yup_core/database/yup_SqliteDatabase.cpp @@ -0,0 +1,544 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +namespace +{ + +//============================================================================== +struct QueryCallbackData +{ + SqliteDatabase::QueryCallback fn; +}; + +int executeQueryCallback (void* userData, int argc, char** argv, char** columnNames) +{ + auto* data = static_cast (userData); + return data->fn (argc, argv, columnNames) ? SQLITE_OK : SQLITE_ABORT; +} + +} // namespace + +//============================================================================== +SqliteDatabase::SqliteDatabase() = default; +SqliteDatabase::~SqliteDatabase() = default; + +bool SqliteDatabase::isOpen() const noexcept +{ + return db != nullptr; +} + +const File& SqliteDatabase::getFile() const noexcept +{ + return file; +} + +Result SqliteDatabase::open (const File& databaseFile, bool readOnly) +{ + close(); + + const int flags = readOnly + ? (SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX) + : (SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX); + + sqlite3* rawDb = nullptr; + + if (sqlite3_open_v2 (databaseFile.getFullPathName().toRawUTF8(), &rawDb, flags, nullptr) != SQLITE_OK) + { + auto error = String::fromUTF8 (sqlite3_errmsg (rawDb)); + sqlite3_close (rawDb); + return Result::fail (error); + } + + db.reset (rawDb, [] (sqlite3* p) + { + sqlite3_close (p); + }); + file = databaseFile; + return Result::ok(); +} + +Result SqliteDatabase::openInMemory() +{ + close(); + + const int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX; + + sqlite3* rawDb = nullptr; + + if (sqlite3_open_v2 (":memory:", &rawDb, flags, nullptr) != SQLITE_OK) + { + auto error = String::fromUTF8 (sqlite3_errmsg (rawDb)); + sqlite3_close (rawDb); + return Result::fail (error); + } + + db.reset (rawDb, [] (sqlite3* p) + { + sqlite3_close (p); + }); + file = File(); + return Result::ok(); +} + +void SqliteDatabase::close() +{ + db.reset(); + file = File(); +} + +Result SqliteDatabase::vacuumToFile (const File& destFile) const +{ + if (db == nullptr) + return Result::fail ("Database is not open"); + + if (destFile.existsAsFile() && ! destFile.deleteFile()) + return Result::fail ("Can't overwrite already existing file"); + + const String path = destFile.getFullPathName(); + const String sql = "VACUUM INTO '" + path.replace ("'", "''") + "'"; + + char* errorMessage = nullptr; + const int rc = sqlite3_exec (db.get(), sql.toRawUTF8(), nullptr, nullptr, &errorMessage); + if (rc != SQLITE_OK) + { + auto error = errorMessage != nullptr + ? String::fromUTF8 (errorMessage) + : String::fromUTF8 (sqlite3_errmsg (db.get())); + sqlite3_free (errorMessage); + return Result::fail (error); + } + + return Result::ok(); +} + +SqliteDatabase::Transaction SqliteDatabase::beginTransaction() +{ + return Transaction (db); +} + +SqliteDatabase::Statement SqliteDatabase::prepareStatement (StringRef sql) +{ + return Statement (db, sql); +} + +Result SqliteDatabase::executeQuery (StringRef sql, QueryCallback callback) +{ + if (db == nullptr) + return Result::fail ("Database is not open"); + + const bool hasCallback = callback != nullptr; + QueryCallbackData callbackData { std::move (callback) }; + + char* errorMessage = nullptr; + + const int rc = sqlite3_exec ( + db.get(), + static_cast (sql), + hasCallback ? executeQueryCallback : nullptr, + hasCallback ? static_cast (&callbackData) : nullptr, + &errorMessage); + + if (rc != SQLITE_OK) + { + auto error = errorMessage != nullptr + ? String::fromUTF8 (errorMessage) + : getLastError(); + sqlite3_free (errorMessage); + return Result::fail (error); + } + + return Result::ok(); +} + +String SqliteDatabase::getLastError() const +{ + if (db == nullptr) + return {}; + + return String::fromUTF8 (sqlite3_errmsg (db.get())); +} + +int SqliteDatabase::statementParamCount (const Statement& stmt) noexcept +{ + if (stmt.stmt == nullptr) + return 0; + + return sqlite3_bind_parameter_count (stmt.stmt.get()); +} + +//============================================================================== +SqliteDatabase::Transaction::Transaction (std::shared_ptr database) + : db (std::move (database)) +{ + if (db != nullptr) + sqlite3_exec (db.get(), "BEGIN;", nullptr, nullptr, nullptr); +} + +SqliteDatabase::Transaction::~Transaction() +{ + if (db == nullptr) + return; + + sqlite3_exec (db.get(), shouldRollback ? "ROLLBACK;" : "COMMIT;", nullptr, nullptr, nullptr); +} + +SqliteDatabase::Transaction::Transaction (Transaction&& other) noexcept + : db (std::exchange (other.db, nullptr)) + , shouldRollback (other.shouldRollback) +{ +} + +SqliteDatabase::Transaction& SqliteDatabase::Transaction::operator= (Transaction&& other) noexcept +{ + if (this != &other) + { + db = std::exchange (other.db, nullptr); + shouldRollback = other.shouldRollback; + } + + return *this; +} + +void SqliteDatabase::Transaction::rollback() noexcept +{ + shouldRollback = true; +} + +//============================================================================== +SqliteDatabase::Statement::Statement (std::shared_ptr database, StringRef sql) + : db (std::move (database)) +{ + if (db == nullptr) + return; + + sqlite3_stmt* rawStmt = nullptr; + + if (sqlite3_prepare_v2 (db.get(), static_cast (sql), -1, &rawStmt, nullptr) != SQLITE_OK) + return; + + stmt.reset (rawStmt, [] (sqlite3_stmt* p) + { + sqlite3_finalize (p); + }); + valid = true; +} + +SqliteDatabase::Statement::~Statement() = default; + +SqliteDatabase::Statement::Statement (Statement&& other) noexcept + : db (std::exchange (other.db, nullptr)) + , stmt (std::exchange (other.stmt, nullptr)) + , valid (std::exchange (other.valid, false)) +{ +} + +SqliteDatabase::Statement& SqliteDatabase::Statement::operator= (Statement&& other) noexcept +{ + if (this != &other) + { + db = std::exchange (other.db, nullptr); + stmt = std::exchange (other.stmt, nullptr); + valid = std::exchange (other.valid, false); + } + + return *this; +} + +bool SqliteDatabase::Statement::isValid() const noexcept +{ + return valid; +} + +//============================================================================== +Result SqliteDatabase::Statement::bindNull (int column) +{ + if (stmt == nullptr) + return Result::fail ("Statement is not valid"); + + if (sqlite3_bind_null (stmt.get(), column) != SQLITE_OK) + return Result::fail (String::fromUTF8 (sqlite3_errmsg (db.get()))); + + return Result::ok(); +} + +Result SqliteDatabase::Statement::bindBool (int column, bool value) +{ + if (stmt == nullptr) + return Result::fail ("Statement is not valid"); + + if (sqlite3_bind_int (stmt.get(), column, value ? 1 : 0) != SQLITE_OK) + return Result::fail (String::fromUTF8 (sqlite3_errmsg (db.get()))); + + return Result::ok(); +} + +Result SqliteDatabase::Statement::bindInt (int column, int value) +{ + if (stmt == nullptr) + return Result::fail ("Statement is not valid"); + + if (sqlite3_bind_int (stmt.get(), column, value) != SQLITE_OK) + return Result::fail (String::fromUTF8 (sqlite3_errmsg (db.get()))); + + return Result::ok(); +} + +Result SqliteDatabase::Statement::bindInt64 (int column, int64_t value) +{ + if (stmt == nullptr) + return Result::fail ("Statement is not valid"); + + if (sqlite3_bind_int64 (stmt.get(), column, static_cast (value)) != SQLITE_OK) + return Result::fail (String::fromUTF8 (sqlite3_errmsg (db.get()))); + + return Result::ok(); +} + +Result SqliteDatabase::Statement::bindDouble (int column, double value) +{ + if (stmt == nullptr) + return Result::fail ("Statement is not valid"); + + if (sqlite3_bind_double (stmt.get(), column, value) != SQLITE_OK) + return Result::fail (String::fromUTF8 (sqlite3_errmsg (db.get()))); + + return Result::ok(); +} + +Result SqliteDatabase::Statement::bindText (int column, StringRef value) +{ + if (stmt == nullptr) + return Result::fail ("Statement is not valid"); + + const char* text = static_cast (value); + + if (sqlite3_bind_text (stmt.get(), column, text, -1, SQLITE_TRANSIENT) != SQLITE_OK) + return Result::fail (String::fromUTF8 (sqlite3_errmsg (db.get()))); + + return Result::ok(); +} + +Result SqliteDatabase::Statement::bindFile (int column, const File& value) +{ + if (stmt == nullptr) + return Result::fail ("Statement is not valid"); + + const String& path = value.getFullPathName(); + + if (sqlite3_bind_text (stmt.get(), column, path.toRawUTF8(), static_cast (path.getNumBytesAsUTF8()), SQLITE_TRANSIENT) != SQLITE_OK) + return Result::fail (String::fromUTF8 (sqlite3_errmsg (db.get()))); + + return Result::ok(); +} + +Result SqliteDatabase::Statement::bindBlob (int column, Span value) +{ + if (stmt == nullptr) + return Result::fail ("Statement is not valid"); + + if (sqlite3_bind_blob (stmt.get(), column, value.data(), static_cast (value.size()), SQLITE_TRANSIENT) != SQLITE_OK) + return Result::fail (String::fromUTF8 (sqlite3_errmsg (db.get()))); + + return Result::ok(); +} + +//============================================================================== +bool SqliteDatabase::Statement::columnBool (int column) const +{ + jassert (stmt != nullptr); + return sqlite3_column_int (stmt.get(), column) != 0; +} + +int SqliteDatabase::Statement::columnInt (int column) const +{ + jassert (stmt != nullptr); + return sqlite3_column_int (stmt.get(), column); +} + +int64_t SqliteDatabase::Statement::columnInt64 (int column) const +{ + jassert (stmt != nullptr); + return static_cast (sqlite3_column_int64 (stmt.get(), column)); +} + +double SqliteDatabase::Statement::columnDouble (int column) const +{ + jassert (stmt != nullptr); + return sqlite3_column_double (stmt.get(), column); +} + +String SqliteDatabase::Statement::columnText (int column) const +{ + jassert (stmt != nullptr); + + const auto* data = sqlite3_column_text (stmt.get(), column); + if (data == nullptr) + return {}; + + const int numBytes = sqlite3_column_bytes (stmt.get(), column); + return String::fromUTF8 (reinterpret_cast (data), numBytes); +} + +File SqliteDatabase::Statement::columnFile (int column) const +{ + jassert (stmt != nullptr); + return File (columnText (column)); +} + +Span SqliteDatabase::Statement::columnBlob (int column) const +{ + jassert (stmt != nullptr); + + const auto* data = sqlite3_column_blob (stmt.get(), column); + const int numBytes = sqlite3_column_bytes (stmt.get(), column); + + return { static_cast (data), static_cast (numBytes) }; +} + +//============================================================================== +std::optional SqliteDatabase::Statement::columnOptionalBool (int column) const +{ + if (stmt == nullptr || sqlite3_column_type (stmt.get(), column) == SQLITE_NULL) + return std::nullopt; + + return sqlite3_column_int (stmt.get(), column) != 0; +} + +std::optional SqliteDatabase::Statement::columnOptionalInt (int column) const +{ + if (stmt == nullptr || sqlite3_column_type (stmt.get(), column) == SQLITE_NULL) + return std::nullopt; + + return sqlite3_column_int (stmt.get(), column); +} + +std::optional SqliteDatabase::Statement::columnOptionalInt64 (int column) const +{ + if (stmt == nullptr || sqlite3_column_type (stmt.get(), column) == SQLITE_NULL) + return std::nullopt; + + return static_cast (sqlite3_column_int64 (stmt.get(), column)); +} + +std::optional SqliteDatabase::Statement::columnOptionalDouble (int column) const +{ + if (stmt == nullptr || sqlite3_column_type (stmt.get(), column) == SQLITE_NULL) + return std::nullopt; + + return sqlite3_column_double (stmt.get(), column); +} + +std::optional SqliteDatabase::Statement::columnOptionalText (int column) const +{ + if (stmt == nullptr || sqlite3_column_type (stmt.get(), column) == SQLITE_NULL) + return std::nullopt; + + const auto* data = sqlite3_column_text (stmt.get(), column); + if (data == nullptr) + return std::nullopt; + + const int numBytes = sqlite3_column_bytes (stmt.get(), column); + return String::fromUTF8 (reinterpret_cast (data), numBytes); +} + +std::optional SqliteDatabase::Statement::columnOptionalFile (int column) const +{ + auto text = columnOptionalText (column); + + if (! text.has_value()) + return std::nullopt; + + return File (*text); +} + +std::optional> SqliteDatabase::Statement::columnOptionalBlob (int column) const +{ + if (stmt == nullptr || sqlite3_column_type (stmt.get(), column) == SQLITE_NULL) + return std::nullopt; + + const auto* data = sqlite3_column_blob (stmt.get(), column); + const int numBytes = sqlite3_column_bytes (stmt.get(), column); + + return Span { static_cast (data), static_cast (numBytes) }; +} + +//============================================================================== +Result SqliteDatabase::Statement::readBlob (StringRef tableName, + StringRef fieldName, + int64_t rowId, + std::function sizeCallback) +{ + if (db == nullptr) + return Result::fail ("Statement has no database connection"); + + sqlite3_blob* blob = nullptr; + + const int openResult = sqlite3_blob_open ( + db.get(), + "main", + static_cast (tableName), + static_cast (fieldName), + static_cast (rowId), + 0, + &blob); + + if (openResult != SQLITE_OK) + { + auto error = String::fromUTF8 (sqlite3_errmsg (db.get())); + if (blob != nullptr) + sqlite3_blob_close (blob); + return Result::fail (error); + } + + const int numBytes = sqlite3_blob_bytes (blob); + void* buffer = sizeCallback (numBytes); + + const int readResult = sqlite3_blob_read (blob, buffer, numBytes, 0); + sqlite3_blob_close (blob); + + if (readResult != SQLITE_OK) + return Result::fail (String::fromUTF8 (sqlite3_errmsg (db.get()))); + + return Result::ok(); +} + +//============================================================================== +int SqliteDatabase::Statement::step() +{ + if (stmt == nullptr) + return SQLITE_MISUSE; + + return sqlite3_step (stmt.get()); +} + +void SqliteDatabase::Statement::reset() +{ + if (stmt == nullptr) + return; + + sqlite3_clear_bindings (stmt.get()); + sqlite3_reset (stmt.get()); +} + +} // namespace yup diff --git a/modules/yup_core/database/yup_SqliteDatabase.h b/modules/yup_core/database/yup_SqliteDatabase.h new file mode 100644 index 000000000..aff0f40ac --- /dev/null +++ b/modules/yup_core/database/yup_SqliteDatabase.h @@ -0,0 +1,416 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + A lightweight wrapper around an SQLite database connection. + + Provides RAII transaction management, compiled prepared statements with + type-safe parameter binding and column access, and a simple one-shot query + execution interface. + + This class is only available when the sqlite3_library module is present + (i.e. when YUP_MODULE_AVAILABLE_sqlite3_library is defined). + + Typical usage: + + @code + SqliteDatabase db; + if (db.open (File::getSpecialLocation (File::tempDirectory).getChildFile ("my.db"))) + { + db.executeQuery ("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)"); + + { + auto tx = db.beginTransaction(); + // One-shot: compile + bind + ready to step, all in one call. + auto result = db.prepareStatement ("INSERT OR REPLACE INTO kv VALUES (?, ?)", + "greeting", "hello"); + if (result.wasOk()) + result.getReference().step(); + } // transaction commits here + + auto result = db.prepareStatement ("SELECT value FROM kv WHERE key = ?", "greeting"); + if (result.wasOk()) + { + auto& sel = result.getReference(); + if (sel.step() == SQLITE_ROW) + DBG (sel.columnText (0)); + } + } + @endcode + + @see SqliteDatabase::Transaction, SqliteDatabase::Statement + + @tags{Core} +*/ +class YUP_API SqliteDatabase +{ +public: + //============================================================================== + /** Callback type used by executeQuery(). + + Receives the column count, value array and column-name array for each row. + Return true to continue iterating, false to stop. + */ + using QueryCallback = std::function; + + //============================================================================== + /** + RAII guard for a database transaction. + + The transaction is committed automatically when this object is destroyed. + Call rollback() before destruction to roll back instead. + */ + class YUP_API Transaction + { + public: + /** Commits (or rolls back) the transaction. */ + ~Transaction(); + + Transaction (const Transaction&) = delete; + Transaction& operator= (const Transaction&) = delete; + + Transaction (Transaction&&) noexcept; + Transaction& operator= (Transaction&&) noexcept; + + /** Marks this transaction to be rolled back on destruction instead of committed. */ + void rollback() noexcept; + + private: + friend class SqliteDatabase; + + explicit Transaction (std::shared_ptr database); + + std::shared_ptr db; + bool shouldRollback = false; + }; + + //============================================================================== + /** + A compiled, reusable prepared statement. + + Bind-parameter indices are 1-based; column indices are 0-based — both + follow standard SQLite conventions. + + The raw data pointers returned by columnBlob() and the Span returned by + columnOptionalBlob() point directly into SQLite's internal buffers and are + only valid until the next call to step() or reset(). Use MemoryBlock or + copy the data if you need it to outlive a single iteration step. + */ + class YUP_API Statement + { + public: + //============================================================================== + /** Finalizes the underlying statement. */ + ~Statement(); + + Statement (const Statement&) = delete; + Statement& operator= (const Statement&) = delete; + + Statement (Statement&&) noexcept; + Statement& operator= (Statement&&) noexcept; + + //============================================================================== + /** Returns true if the statement was compiled successfully. */ + bool isValid() const noexcept; + + //============================================================================== + /** Binds SQL NULL to a parameter. */ + Result bindNull (int column); + + /** Binds a bool value (stored as integer 0 or 1). */ + Result bindBool (int column, bool value); + + /** Binds a 32-bit signed integer. */ + Result bindInt (int column, int value); + + /** Binds a 64-bit signed integer. */ + Result bindInt64 (int column, int64_t value); + + /** Binds a double-precision floating-point value. */ + Result bindDouble (int column, double value); + + /** Binds a UTF-8 text value (SQLite makes an internal copy). */ + Result bindText (int column, StringRef value); + + /** Binds a File's full path as UTF-8 text (SQLite makes an internal copy). */ + Result bindFile (int column, const File& value); + + /** Binds raw binary data (SQLite makes an internal copy). */ + Result bindBlob (int column, Span value); + + //============================================================================== + /** @name Column readers (0-based indices) + + These methods assert in debug builds when the statement is invalid. + Use the optional variants if you need to detect SQL NULL. + */ + ///@{ + bool columnBool (int column) const; + int columnInt (int column) const; + int64_t columnInt64 (int column) const; + double columnDouble (int column) const; + + /** Returns a copy of the column's UTF-8 text as a String. */ + String columnText (int column) const; + + /** Interprets the column's text as a file path and returns a File. */ + File columnFile (int column) const; + + /** Returns a non-owning view of the raw blob bytes. + The data is only valid until the next step() or reset() call. + */ + Span columnBlob (int column) const; + ///@} + + //============================================================================== + /** @name Nullable column readers (0-based indices) + + Return std::nullopt when the column contains SQL NULL. + */ + ///@{ + std::optional columnOptionalBool (int column) const; + std::optional columnOptionalInt (int column) const; + std::optional columnOptionalInt64 (int column) const; + std::optional columnOptionalDouble (int column) const; + std::optional columnOptionalText (int column) const; + std::optional columnOptionalFile (int column) const; + std::optional> columnOptionalBlob (int column) const; + ///@} + + //============================================================================== + /** + Reads a BLOB column using SQLite's incremental I/O interface. + + This avoids loading the entire blob into memory before copying it. + The @p sizeCallback is invoked with the blob size in bytes; it must + return a writable buffer of exactly that size. The data is then + read directly into that buffer. + + @param tableName The table that contains the blob column. + @param fieldName The column name. + @param rowId The rowid of the target row. + @param sizeCallback Called with the byte count; returns the target buffer. + + @returns Result::ok() on success, Result::fail() with an error message otherwise. + */ + Result readBlob (StringRef tableName, + StringRef fieldName, + int64_t rowId, + std::function sizeCallback); + + //============================================================================== + /** Advances the statement one step. + Returns SQLITE_ROW when a result row is available, SQLITE_DONE when + execution is complete, or an error code otherwise. + */ + int step(); + + /** Clears all bound parameters and resets the statement for reuse. */ + void reset(); + + private: + friend class SqliteDatabase; + + Statement (std::shared_ptr database, StringRef sql); + + std::shared_ptr db; + std::shared_ptr stmt; + bool valid = false; + }; + + //============================================================================== + /** Construct a closed sqlite database. */ + SqliteDatabase(); + + /** Close a sqlite database. */ + ~SqliteDatabase(); + + SqliteDatabase (const SqliteDatabase& other) = default; + SqliteDatabase& operator= (const SqliteDatabase& other) = default; + SqliteDatabase (SqliteDatabase&& other) = default; + SqliteDatabase& operator= (SqliteDatabase&& other) = default; + + //============================================================================== + /** Returns true if the database is currently open. */ + bool isOpen() const noexcept; + + /** Returns the File that was used to open the database. + Returns a default-constructed (empty) File when the database is closed. + */ + const File& getFile() const noexcept; + + //============================================================================== + /** + Opens the database at the given file path. + + If @p readOnly is false and the file does not exist it is created. + + @returns Result::ok() on success, Result::fail() with an error message otherwise. + */ + Result open (const File& databaseFile, bool readOnly = false); + + /** + Opens a private, temporary in-memory database. + + The database exists only for the lifetime of this connection and is + not backed by any file. getFile() returns an empty File for + in-memory databases. + + @returns Result::ok() on success, Result::fail() with an error message otherwise. + */ + Result openInMemory(); + + /** Closes the database connection and resets the file path. */ + void close(); + + //============================================================================== + /** + Creates a compacted copy of the database at the given file path. + + Uses SQLite's @c VACUUM INTO statement, which writes a defragmented + snapshot of the current database (including in-memory databases) to a + new file without disturbing the source connection. + + Requires SQLite 3.27.0 or later. + + @param file Destination path. The file must not already be open as a + SQLite database, but it may already exist (it will be + overwritten). + + @returns Result::ok() on success, Result::fail() with an error message otherwise. + */ + Result vacuumToFile (const File& file) const; + + //============================================================================== + /** Begins a new transaction and returns an RAII guard. + The guard commits on destruction unless rollback() is called first. + */ + Transaction beginTransaction(); + + //============================================================================== + /** Compiles a SQL statement and returns a reusable Statement object. + Check Statement::isValid() to verify compilation succeeded. + */ + Statement prepareStatement (StringRef sql); + + /** + Compiles a SQL statement and immediately binds @p arg and @p rest to its + parameters (1-based, in order). + + Supported argument types: bool, any integral, any floating-point, + StringRef / String / const char*, File, Span, + nullptr_t / nullopt_t (binds SQL NULL). + + @returns ResultValue::ok() holding the ready-to-step Statement on success, + or ResultValue::fail() with an error message if compilation or + any binding fails. + */ + template + ResultValue prepareStatement (StringRef sql, Arg&& arg, Rest&&... rest) + { + constexpr int numArgs = 1 + static_cast (sizeof...(Rest)); + + auto stmt = prepareStatement (sql); + + if (! stmt.isValid()) + return makeResultValueFail (getLastError()); + + const int expectedParams = statementParamCount (stmt); + if (numArgs != expectedParams) + return makeResultValueFail ( + "Expected " + String (expectedParams) + " bind parameter(s), got " + String (numArgs)); + + if (auto r = bindArgPack (stmt, 1, std::forward (arg), std::forward (rest)...); r.failed()) + return makeResultValueFail (r.getErrorMessage()); + + return makeResultValueOk (std::move (stmt)); + } + + //============================================================================== + /** + Executes a SQL string immediately, invoking @p callback for each result row. + + @p callback may be nullptr when no results are expected (e.g. DDL or DML). + @returns Result::ok() on success, Result::fail() with an error message otherwise. + */ + Result executeQuery (StringRef sql, QueryCallback callback = nullptr); + + //============================================================================== + /** Returns the most recent error message from the underlying SQLite connection. + Returns an empty string when the database is not open. + */ + String getLastError() const; + +private: + static int statementParamCount (const Statement& stmt) noexcept; + + template + static constexpr bool unsupportedBindType = false; + + template + static Result bindSingleArg (Statement& stmt, int column, T&& value) + { + using V = std::decay_t; + + if constexpr (std::is_same_v || std::is_same_v) + return stmt.bindNull (column); + else if constexpr (std::is_same_v) + return stmt.bindBool (column, value); + else if constexpr (std::is_integral_v && sizeof (V) <= 4) + return stmt.bindInt (column, static_cast (value)); + else if constexpr (std::is_integral_v) + return stmt.bindInt64 (column, static_cast (value)); + else if constexpr (std::is_floating_point_v) + return stmt.bindDouble (column, static_cast (value)); + else if constexpr (std::is_same_v) + return stmt.bindFile (column, value); + else if constexpr (std::is_constructible_v) + return stmt.bindText (column, StringRef (std::forward (value))); + else if constexpr (std::is_constructible_v, V>) + return stmt.bindBlob (column, Span (std::forward (value))); + else + static_assert (unsupportedBindType, + "SqliteDatabase::prepareStatement: no SQLite binding for this argument type"); + } + + template + static Result bindArgPack (Statement& stmt, int column, Head&& head, Tail&&... tail) + { + if (auto r = bindSingleArg (stmt, column, std::forward (head)); r.failed()) + return r; + + if constexpr (sizeof...(Tail) > 0) + return bindArgPack (stmt, column + 1, std::forward (tail)...); + + return Result::ok(); + } + + std::shared_ptr db; + File file; + + YUP_LEAK_DETECTOR (SqliteDatabase) +}; + +} // namespace yup diff --git a/modules/yup_core/yup_core.cpp b/modules/yup_core/yup_core.cpp index dbb17b9dd..57fd7f003 100644 --- a/modules/yup_core/yup_core.cpp +++ b/modules/yup_core/yup_core.cpp @@ -340,6 +340,11 @@ extern char** environ; //============================================================================== #include "files/yup_Watchdog.cpp" +//============================================================================== +#if YUP_MODULE_AVAILABLE_sqlite3_library +#include "database/yup_SqliteDatabase.cpp" +#endif + //============================================================================== namespace yup { diff --git a/modules/yup_core/yup_core.h b/modules/yup_core/yup_core.h index 2605b8eaa..31595111b 100644 --- a/modules/yup_core/yup_core.h +++ b/modules/yup_core/yup_core.h @@ -388,6 +388,11 @@ YUP_END_IGNORE_WARNINGS_MSVC #include "files/yup_Watchdog.h" #include "streams/yup_AndroidDocumentInputSource.h" +#if YUP_MODULE_AVAILABLE_sqlite3_library +#include "sqlite3_library/sqlite3_library.h" +#include "database/yup_SqliteDatabase.h" +#endif + #include "detail/yup_CallbackListenerList.h" #if YUP_CORE_INCLUDE_OBJC_HELPERS && (YUP_MAC || YUP_IOS) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 685900b61..bcd736f44 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -67,7 +67,8 @@ set (target_modules opus_library flac_library hmp3_library - bungee_library) + bungee_library + sqlite3_library) if (NOT YUP_PLATFORM_EMSCRIPTEN) list (APPEND target_modules yup_gui yup_audio_gui) diff --git a/tests/yup_core/yup_SqliteDatabase.cpp b/tests/yup_core/yup_SqliteDatabase.cpp new file mode 100644 index 000000000..0fc3063b9 --- /dev/null +++ b/tests/yup_core/yup_SqliteDatabase.cpp @@ -0,0 +1,1355 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +#if YUP_MODULE_AVAILABLE_sqlite3_library + +//============================================================================== +// Helper to open an in-memory database and assert success. +static SqliteDatabase openMemory() +{ + SqliteDatabase db; + EXPECT_TRUE (db.openInMemory()); + return db; +} + +//============================================================================== +class SqliteDatabaseTests : public ::testing::Test +{ +protected: + void SetUp() override + { + db = openMemory(); + } + + SqliteDatabase db; +}; + +//============================================================================== +// Construction & lifecycle +//============================================================================== + +TEST (SqliteDatabaseLifecycleTests, DefaultConstructionIsNotOpen) +{ + SqliteDatabase db; + EXPECT_FALSE (db.isOpen()); + EXPECT_TRUE (db.getFile().getFullPathName().isEmpty()); +} + +TEST (SqliteDatabaseLifecycleTests, OpenInMemorySucceeds) +{ + SqliteDatabase db; + EXPECT_TRUE (db.openInMemory()); + EXPECT_TRUE (db.isOpen()); + EXPECT_TRUE (db.getFile().getFullPathName().isEmpty()); +} + +TEST (SqliteDatabaseLifecycleTests, CloseResetsState) +{ + SqliteDatabase db; + db.openInMemory(); + db.close(); + EXPECT_FALSE (db.isOpen()); + EXPECT_TRUE (db.getFile().getFullPathName().isEmpty()); +} + +TEST (SqliteDatabaseLifecycleTests, OpenAgainClosesExistingConnection) +{ + SqliteDatabase db; + EXPECT_TRUE (db.openInMemory()); + EXPECT_TRUE (db.openInMemory()); // should close the first and reopen + EXPECT_TRUE (db.isOpen()); +} + +TEST (SqliteDatabaseLifecycleTests, OpenReadOnlyNonExistentFileFails) +{ + SqliteDatabase db; + File nonExistent (File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_no_such_db_" + String::toHexString (Random::getSystemRandom().nextInt()) + ".db")); + + EXPECT_FALSE (db.open (nonExistent, true)); + EXPECT_FALSE (db.isOpen()); +} + +TEST (SqliteDatabaseLifecycleTests, OpenFileBasedDatabase) +{ + File tempDb = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_sqlite_test_" + String::toHexString (Random::getSystemRandom().nextInt()) + ".db"); + + { + SqliteDatabase db; + EXPECT_TRUE (db.open (tempDb)); + EXPECT_TRUE (db.executeQuery ("CREATE TABLE t (v INTEGER)")); + EXPECT_TRUE (db.executeQuery ("INSERT INTO t VALUES (99)")); + } + + // Reopen and verify data persisted. + { + SqliteDatabase db; + EXPECT_TRUE (db.open (tempDb)); + int value = 0; + db.executeQuery ("SELECT v FROM t", [&] (int, char** argv, char**) + { + value = std::stoi (argv[0]); + return true; + }); + EXPECT_EQ (99, value); + } + + tempDb.deleteFile(); +} + +//============================================================================== +// executeQuery +//============================================================================== + +TEST_F (SqliteDatabaseTests, ExecuteQueryCreateTable) +{ + EXPECT_TRUE (db.executeQuery ("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")); +} + +TEST_F (SqliteDatabaseTests, ExecuteQueryBadSqlReturnsFalse) +{ + EXPECT_FALSE (db.executeQuery ("THIS IS NOT SQL AT ALL")); +} + +TEST_F (SqliteDatabaseTests, ExecuteQueryOnClosedDatabaseReturnsFalse) +{ + SqliteDatabase closed; + EXPECT_FALSE (closed.executeQuery ("CREATE TABLE t (x INTEGER)")); +} + +TEST_F (SqliteDatabaseTests, ExecuteQueryWithCallbackReceivesRows) +{ + db.executeQuery ("CREATE TABLE kv (key TEXT, value TEXT)"); + db.executeQuery ("INSERT INTO kv VALUES ('a', 'apple')"); + db.executeQuery ("INSERT INTO kv VALUES ('b', 'banana')"); + + std::vector> rows; + db.executeQuery ("SELECT key, value FROM kv ORDER BY key", + [&] (int argc, char** argv, char** /*colNames*/) + { + EXPECT_EQ (2, argc); + rows.emplace_back (argv[0], argv[1]); + return true; + }); + + ASSERT_EQ (2u, rows.size()); + EXPECT_EQ ("a", rows[0].first); + EXPECT_EQ ("apple", rows[0].second); + EXPECT_EQ ("b", rows[1].first); + EXPECT_EQ ("banana", rows[1].second); +} + +TEST_F (SqliteDatabaseTests, ExecuteQueryCallbackReturnFalseAbortsEarly) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (1)"); + db.executeQuery ("INSERT INTO t VALUES (2)"); + db.executeQuery ("INSERT INTO t VALUES (3)"); + + int callCount = 0; + db.executeQuery ("SELECT v FROM t ORDER BY v", + [&] (int, char**, char**) + { + ++callCount; + return false; // stop after first row + }); + + EXPECT_EQ (1, callCount); +} + +//============================================================================== +// getLastError +//============================================================================== + +TEST (SqliteDatabaseErrorTests, GetLastErrorOnClosedDatabaseReturnsEmpty) +{ + SqliteDatabase db; + EXPECT_TRUE (db.getLastError().isEmpty()); +} + +TEST_F (SqliteDatabaseTests, GetLastErrorAfterBadSqlIsNotEmpty) +{ + db.executeQuery ("COMPLETELY WRONG"); + EXPECT_TRUE (db.getLastError().isNotEmpty()); +} + +//============================================================================== +// prepareStatement / Statement::isValid +//============================================================================== + +TEST_F (SqliteDatabaseTests, PrepareValidStatementIsValid) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + auto stmt = db.prepareStatement ("SELECT v FROM t"); + EXPECT_TRUE (stmt.isValid()); +} + +TEST_F (SqliteDatabaseTests, PrepareBadSqlIsNotValid) +{ + auto stmt = db.prepareStatement ("GARBAGE QUERY !!!"); + EXPECT_FALSE (stmt.isValid()); +} + +TEST (SqliteDatabaseStatementTests, PrepareOnNullDatabaseIsNotValid) +{ + SqliteDatabase closed; + auto stmt = closed.prepareStatement ("SELECT 1"); + EXPECT_FALSE (stmt.isValid()); +} + +//============================================================================== +// Statement move semantics +//============================================================================== + +TEST_F (SqliteDatabaseTests, StatementMoveConstructor) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + auto original = db.prepareStatement ("SELECT v FROM t"); + EXPECT_TRUE (original.isValid()); + + auto moved = std::move (original); + EXPECT_TRUE (moved.isValid()); + EXPECT_FALSE (original.isValid()); +} + +TEST_F (SqliteDatabaseTests, StatementMoveAssignment) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + auto stmtA = db.prepareStatement ("SELECT v FROM t"); + SqliteDatabase::Statement stmtB (std::move (stmtA)); + EXPECT_FALSE (stmtA.isValid()); + EXPECT_TRUE (stmtB.isValid()); +} + +//============================================================================== +// step / reset +//============================================================================== + +TEST_F (SqliteDatabaseTests, StepInsertReturnsDone) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + auto stmt = db.prepareStatement ("INSERT INTO t VALUES (1)"); + EXPECT_EQ (SQLITE_DONE, stmt.step()); +} + +TEST_F (SqliteDatabaseTests, StepSelectReturnsRow) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (42)"); + auto stmt = db.prepareStatement ("SELECT v FROM t"); + EXPECT_EQ (SQLITE_ROW, stmt.step()); +} + +TEST_F (SqliteDatabaseTests, StepExhaustedReturnsDone) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + auto stmt = db.prepareStatement ("SELECT v FROM t"); + EXPECT_EQ (SQLITE_DONE, stmt.step()); +} + +TEST_F (SqliteDatabaseTests, StepOnInvalidStatementReturnsMisuse) +{ + SqliteDatabase::Statement invalid (db.prepareStatement ("BROKEN !!")); + EXPECT_FALSE (invalid.isValid()); + EXPECT_EQ (SQLITE_MISUSE, invalid.step()); +} + +TEST_F (SqliteDatabaseTests, ResetAllowsReuse) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (7)"); + db.executeQuery ("INSERT INTO t VALUES (8)"); + + auto stmt = db.prepareStatement ("SELECT v FROM t ORDER BY v"); + EXPECT_EQ (SQLITE_ROW, stmt.step()); + EXPECT_EQ (7, stmt.columnInt (0)); + EXPECT_EQ (SQLITE_ROW, stmt.step()); + EXPECT_EQ (8, stmt.columnInt (0)); + + stmt.reset(); + + // After reset, step from the beginning again. + EXPECT_EQ (SQLITE_ROW, stmt.step()); + EXPECT_EQ (7, stmt.columnInt (0)); +} + +TEST_F (SqliteDatabaseTests, IterateMultipleRowsWithStep) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + for (int i = 1; i <= 10; ++i) + db.executeQuery ("INSERT INTO t VALUES (" + String (i) + ")"); + + auto stmt = db.prepareStatement ("SELECT v FROM t ORDER BY v"); + int expected = 1; + while (stmt.step() == SQLITE_ROW) + EXPECT_EQ (expected++, stmt.columnInt (0)); + + EXPECT_EQ (11, expected); +} + +//============================================================================== +// Bind / Column — bool +//============================================================================== + +TEST_F (SqliteDatabaseTests, BindAndColumnBool) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ins.bindBool (1, true); + ins.step(); + ins.reset(); + ins.bindBool (1, false); + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t ORDER BY v"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnBool (0)); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_TRUE (sel.columnBool (0)); +} + +//============================================================================== +// Bind / Column — int +//============================================================================== + +TEST_F (SqliteDatabaseTests, BindAndColumnInt) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + for (int v : { -100, 0, 1, std::numeric_limits::max() }) + { + ins.bindInt (1, v); + ins.step(); + ins.reset(); + } + } + + auto sel = db.prepareStatement ("SELECT v FROM t ORDER BY v"); + std::vector results; + while (sel.step() == SQLITE_ROW) + results.push_back (sel.columnInt (0)); + + ASSERT_EQ (4u, results.size()); + EXPECT_EQ (-100, results[0]); + EXPECT_EQ (0, results[1]); + EXPECT_EQ (1, results[2]); + EXPECT_EQ (std::numeric_limits::max(), results[3]); +} + +//============================================================================== +// Bind / Column — int64 +//============================================================================== + +TEST_F (SqliteDatabaseTests, BindAndColumnInt64) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + const int64_t large = static_cast (std::numeric_limits::max()) * 4LL + 1; + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ins.bindInt64 (1, large); + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ (large, sel.columnInt64 (0)); +} + +//============================================================================== +// Bind / Column — double +//============================================================================== + +TEST_F (SqliteDatabaseTests, BindAndColumnDouble) +{ + db.executeQuery ("CREATE TABLE t (v REAL)"); + const double pi = 3.141592653589793; + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ins.bindDouble (1, pi); + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_DOUBLE_EQ (pi, sel.columnDouble (0)); +} + +//============================================================================== +// Bind / Column — text +//============================================================================== + +TEST_F (SqliteDatabaseTests, BindAndColumnText) +{ + db.executeQuery ("CREATE TABLE t (v TEXT)"); + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ins.bindText (1, "hello world"); + ins.step(); + ins.reset(); + ins.bindText (1, ""); // empty string + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t ORDER BY v"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ ("", sel.columnText (0)); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ ("hello world", sel.columnText (0)); +} + +TEST_F (SqliteDatabaseTests, BindAndColumnTextUnicode) +{ + db.executeQuery ("CREATE TABLE t (v TEXT)"); + const String unicode = String::fromUTF8 ("\xE3\x81\x93\xE3\x82\x93\xE3\x81\xAB\xE3\x81\xA1\xE3\x81\xAF"); // "こんにちは" + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ins.bindText (1, unicode); + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ (unicode, sel.columnText (0)); +} + +//============================================================================== +// Bind / Column — File +//============================================================================== + +TEST_F (SqliteDatabaseTests, BindAndColumnFile) +{ + db.executeQuery ("CREATE TABLE t (v TEXT)"); + const File f ("/usr/local/bin/sqlite3"); + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ins.bindFile (1, f); + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ (f.getFullPathName(), sel.columnFile (0).getFullPathName()); + EXPECT_EQ (f.getFullPathName(), sel.columnText (0)); +} + +//============================================================================== +// Bind / Column — blob +//============================================================================== + +TEST_F (SqliteDatabaseTests, BindAndColumnBlob) +{ + db.executeQuery ("CREATE TABLE t (v BLOB)"); + const std::vector blobData = { 0x00, 0xDE, 0xAD, 0xBE, 0xEF, 0xFF }; + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ins.bindBlob (1, Span (blobData.data(), blobData.size())); + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + + const auto blob = sel.columnBlob (0); + ASSERT_EQ (blobData.size(), blob.size()); + EXPECT_EQ (0, std::memcmp (blobData.data(), blob.data(), blobData.size())); +} + +TEST_F (SqliteDatabaseTests, BindEmptyBlob) +{ + db.executeQuery ("CREATE TABLE t (v BLOB)"); + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ins.bindBlob (1, Span()); + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + // An empty blob may come back as NULL or as a zero-size blob — both are valid. + const auto type = sel.columnOptionalBlob (0); + if (type.has_value()) + EXPECT_EQ (0u, type->size()); +} + +//============================================================================== +// bindNull + optional columns +//============================================================================== + +TEST_F (SqliteDatabaseTests, BindNullMakesColumnNull) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ins.bindNull (1); + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnOptionalInt (0).has_value()); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalBoolReturnsNulloptForNull) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (NULL)"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnOptionalBool (0).has_value()); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalBoolReturnsValueForNonNull) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (1)"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + ASSERT_TRUE (sel.columnOptionalBool (0).has_value()); + EXPECT_TRUE (*sel.columnOptionalBool (0)); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalIntNullopt) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (NULL)"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnOptionalInt (0).has_value()); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalIntValue) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (42)"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + ASSERT_TRUE (sel.columnOptionalInt (0).has_value()); + EXPECT_EQ (42, *sel.columnOptionalInt (0)); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalInt64) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + const int64_t big = 9000000000LL; + db.executeQuery ("INSERT INTO t VALUES (" + String (big) + ")"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + ASSERT_TRUE (sel.columnOptionalInt64 (0).has_value()); + EXPECT_EQ (big, *sel.columnOptionalInt64 (0)); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalDoubleNullopt) +{ + db.executeQuery ("CREATE TABLE t (v REAL)"); + db.executeQuery ("INSERT INTO t VALUES (NULL)"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnOptionalDouble (0).has_value()); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalDoubleValue) +{ + db.executeQuery ("CREATE TABLE t (v REAL)"); + db.executeQuery ("INSERT INTO t VALUES (2.718)"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + ASSERT_TRUE (sel.columnOptionalDouble (0).has_value()); + EXPECT_DOUBLE_EQ (2.718, *sel.columnOptionalDouble (0)); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalTextNullopt) +{ + db.executeQuery ("CREATE TABLE t (v TEXT)"); + db.executeQuery ("INSERT INTO t VALUES (NULL)"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnOptionalText (0).has_value()); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalTextValue) +{ + db.executeQuery ("CREATE TABLE t (v TEXT)"); + db.executeQuery ("INSERT INTO t VALUES ('yup')"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + ASSERT_TRUE (sel.columnOptionalText (0).has_value()); + EXPECT_EQ ("yup", *sel.columnOptionalText (0)); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalFileNullopt) +{ + db.executeQuery ("CREATE TABLE t (v TEXT)"); + db.executeQuery ("INSERT INTO t VALUES (NULL)"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnOptionalFile (0).has_value()); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalFileValue) +{ + db.executeQuery ("CREATE TABLE t (v TEXT)"); + db.executeQuery ("INSERT INTO t VALUES ('/tmp/foo.db')"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + ASSERT_TRUE (sel.columnOptionalFile (0).has_value()); + EXPECT_EQ ("/tmp/foo.db", sel.columnOptionalFile (0)->getFullPathName()); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalBlobNullopt) +{ + db.executeQuery ("CREATE TABLE t (v BLOB)"); + db.executeQuery ("INSERT INTO t VALUES (NULL)"); + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnOptionalBlob (0).has_value()); +} + +TEST_F (SqliteDatabaseTests, ColumnOptionalBlobValue) +{ + db.executeQuery ("CREATE TABLE t (v BLOB)"); + const std::vector data = { 0x01, 0x02, 0x03 }; + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ins.bindBlob (1, Span (data.data(), data.size())); + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + auto result = sel.columnOptionalBlob (0); + ASSERT_TRUE (result.has_value()); + ASSERT_EQ (3u, result->size()); + EXPECT_EQ (0x01, (*result)[0]); + EXPECT_EQ (0x02, (*result)[1]); + EXPECT_EQ (0x03, (*result)[2]); +} + +//============================================================================== +// Bind on invalid statement returns false +//============================================================================== + +TEST (SqliteDatabaseStatementInvalidTests, BindOnInvalidStatementReturnsFalse) +{ + SqliteDatabase db; + auto stmt = db.prepareStatement ("SELECT 1"); // db not open + EXPECT_FALSE (stmt.isValid()); + EXPECT_FALSE (stmt.bindNull (1)); + EXPECT_FALSE (stmt.bindBool (1, true)); + EXPECT_FALSE (stmt.bindInt (1, 0)); + EXPECT_FALSE (stmt.bindInt64 (1, 0)); + EXPECT_FALSE (stmt.bindDouble (1, 0.0)); + EXPECT_FALSE (stmt.bindText (1, "x")); + EXPECT_FALSE (stmt.bindFile (1, File())); + EXPECT_FALSE (stmt.bindBlob (1, Span())); +} + +//============================================================================== +// Transaction — commit +//============================================================================== + +TEST_F (SqliteDatabaseTests, TransactionCommitsOnDestruction) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + + { + auto tx = db.beginTransaction(); + auto ins = db.prepareStatement ("INSERT INTO t VALUES (1)"); + ins.step(); + } // tx commits here + + auto sel = db.prepareStatement ("SELECT COUNT(*) FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ (1, sel.columnInt (0)); +} + +TEST_F (SqliteDatabaseTests, TransactionRollbackPreventsPersistence) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + + { + auto tx = db.beginTransaction(); + auto ins = db.prepareStatement ("INSERT INTO t VALUES (1)"); + ins.step(); + tx.rollback(); + } // tx rolls back here + + auto sel = db.prepareStatement ("SELECT COUNT(*) FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ (0, sel.columnInt (0)); +} + +TEST_F (SqliteDatabaseTests, TransactionCanContainMultipleInserts) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + + { + auto tx = db.beginTransaction(); + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + for (int i = 1; i <= 100; ++i) + { + ins.bindInt (1, i); + ins.step(); + ins.reset(); + } + } + + auto sel = db.prepareStatement ("SELECT COUNT(*) FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ (100, sel.columnInt (0)); +} + +//============================================================================== +// Transaction — move semantics +//============================================================================== + +TEST_F (SqliteDatabaseTests, TransactionMoveConstructorTransfersOwnership) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + + { + auto tx1 = db.beginTransaction(); + auto ins = db.prepareStatement ("INSERT INTO t VALUES (5)"); + ins.step(); + + auto tx2 = std::move (tx1); + // tx1.db is now null — its destructor is a no-op. + // tx2 holds the connection and commits on destruction. + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ (5, sel.columnInt (0)); +} + +TEST_F (SqliteDatabaseTests, MovedFromTransactionRollsBackViaNewOwner) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + + { + auto tx = db.beginTransaction(); + auto ins = db.prepareStatement ("INSERT INTO t VALUES (99)"); + ins.step(); + + auto tx2 = std::move (tx); + tx2.rollback(); + // tx2 will rollback; tx (moved-from, null db) does nothing + } + + auto sel = db.prepareStatement ("SELECT COUNT(*) FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ (0, sel.columnInt (0)); +} + +//============================================================================== +// readBlob +//============================================================================== + +TEST_F (SqliteDatabaseTests, ReadBlobReadsDataIntoBuffer) +{ + // SQLite incremental blob I/O requires the table to have an explicit rowid. + db.executeQuery ("CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB)"); + + const std::vector original = { 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x01, 0x02, 0x03 }; + { + auto ins = db.prepareStatement ("INSERT INTO blobs (id, data) VALUES (1, ?)"); + ins.bindBlob (1, Span (original.data(), original.size())); + ins.step(); + } + + auto stmt = db.prepareStatement ("SELECT 1"); // statement just needs a valid db handle + std::vector readBack; + + const bool ok = stmt.readBlob ("blobs", "data", 1, [&] (int numBytes) -> void* + { + readBack.resize (static_cast (numBytes)); + return readBack.data(); + }); + + EXPECT_TRUE (ok); + ASSERT_EQ (original.size(), readBack.size()); + EXPECT_EQ (0, std::memcmp (original.data(), readBack.data(), original.size())); +} + +TEST (SqliteDatabaseReadBlobTests, ReadBlobOnClosedDatabaseReturnsFalse) +{ + SqliteDatabase closed; + auto stmt = closed.prepareStatement ("SELECT 1"); + bool called = false; + EXPECT_FALSE (stmt.readBlob ("t", "data", 1, [&] (int) -> void* + { + called = true; + return nullptr; + })); + EXPECT_FALSE (called); +} + +TEST_F (SqliteDatabaseTests, ReadBlobWithWrongTableReturnsFalse) +{ + auto stmt = db.prepareStatement ("SELECT 1"); + EXPECT_FALSE (stmt.readBlob ("no_such_table", "no_such_col", 1, [] (int) -> void* + { + return nullptr; + })); +} + +//============================================================================== +// Parameterised statement reuse +//============================================================================== + +TEST_F (SqliteDatabaseTests, ReusedStatementWithResetProducesCorrectResults) +{ + db.executeQuery ("CREATE TABLE nums (v INTEGER)"); + + auto ins = db.prepareStatement ("INSERT INTO nums VALUES (?)"); + for (int i = 1; i <= 5; ++i) + { + EXPECT_TRUE (ins.isValid()); + EXPECT_TRUE (ins.bindInt (1, i * i)); + EXPECT_EQ (SQLITE_DONE, ins.step()); + ins.reset(); + } + + auto sel = db.prepareStatement ("SELECT v FROM nums ORDER BY v"); + int count = 0; + while (sel.step() == SQLITE_ROW) + { + const int expected = (count + 1) * (count + 1); + EXPECT_EQ (expected, sel.columnInt (0)); + ++count; + } + EXPECT_EQ (5, count); +} + +//============================================================================== +// Mixed NULL and non-NULL rows +//============================================================================== + +TEST_F (SqliteDatabaseTests, MixedNullAndNonNullRows) +{ + db.executeQuery ("CREATE TABLE t (a INTEGER, b TEXT)"); + db.executeQuery ("INSERT INTO t VALUES (1, 'one')"); + db.executeQuery ("INSERT INTO t VALUES (NULL, NULL)"); + db.executeQuery ("INSERT INTO t VALUES (3, 'three')"); + + auto sel = db.prepareStatement ("SELECT a, b FROM t ORDER BY COALESCE(a, 2)"); + + ASSERT_EQ (SQLITE_ROW, sel.step()); + ASSERT_TRUE (sel.columnOptionalInt (0).has_value()); + ASSERT_TRUE (sel.columnOptionalText (1).has_value()); + EXPECT_EQ (1, *sel.columnOptionalInt (0)); + EXPECT_EQ ("one", *sel.columnOptionalText (1)); + + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnOptionalInt (0).has_value()); + EXPECT_FALSE (sel.columnOptionalText (1).has_value()); + + ASSERT_EQ (SQLITE_ROW, sel.step()); + ASSERT_TRUE (sel.columnOptionalInt (0).has_value()); + ASSERT_TRUE (sel.columnOptionalText (1).has_value()); + EXPECT_EQ (3, *sel.columnOptionalInt (0)); + EXPECT_EQ ("three", *sel.columnOptionalText (1)); +} + +//============================================================================== +// executeQuery with named parameters works via prepareStatement +//============================================================================== + +TEST_F (SqliteDatabaseTests, PrepareStatementWithNamedParameters) +{ + db.executeQuery ("CREATE TABLE t (id INTEGER, label TEXT)"); + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (:id, :label)"); + EXPECT_TRUE (ins.isValid()); + + // Named parameters are still bound by positional index (1-based). + ins.bindInt (1, 7); + ins.bindText (2, "seven"); + ins.step(); + } + + auto sel = db.prepareStatement ("SELECT id, label FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ (7, sel.columnInt (0)); + EXPECT_EQ ("seven", sel.columnText (1)); +} + +//============================================================================== +// Result error messages +//============================================================================== + +TEST (SqliteDatabaseResultTests, OpenReadOnlyMissingFileHasErrorMessage) +{ + SqliteDatabase db; + File nonExistent (File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_missing_" + String::toHexString (Random::getSystemRandom().nextInt()) + ".db")); + + auto result = db.open (nonExistent, true); + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (result.getErrorMessage().isNotEmpty()); +} + +TEST_F (SqliteDatabaseTests, ExecuteQueryBadSqlHasErrorMessage) +{ + auto result = db.executeQuery ("THIS IS NOT SQL"); + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (result.getErrorMessage().isNotEmpty()); +} + +TEST_F (SqliteDatabaseTests, ExecuteQueryOnClosedDatabaseHasErrorMessage) +{ + SqliteDatabase closed; + auto result = closed.executeQuery ("SELECT 1"); + EXPECT_FALSE (result.wasOk()); + EXPECT_EQ ("Database is not open", result.getErrorMessage()); +} + +TEST_F (SqliteDatabaseTests, BindOnInvalidStatementHasErrorMessage) +{ + SqliteDatabase::Statement invalid = db.prepareStatement ("BROKEN !!!"); + EXPECT_FALSE (invalid.isValid()); + + auto r = invalid.bindInt (1, 0); + EXPECT_FALSE (r.wasOk()); + EXPECT_EQ ("Statement is not valid", r.getErrorMessage()); +} + +TEST_F (SqliteDatabaseTests, BindColumnOutOfRangeHasErrorMessage) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + auto stmt = db.prepareStatement ("INSERT INTO t VALUES (?)"); + ASSERT_TRUE (stmt.isValid()); + + // Column 99 doesn't exist — sqlite3 returns SQLITE_RANGE. + auto r = stmt.bindInt (99, 0); + EXPECT_FALSE (r.wasOk()); + EXPECT_TRUE (r.getErrorMessage().isNotEmpty()); +} + +TEST_F (SqliteDatabaseTests, ReadBlobOnInvalidTableHasErrorMessage) +{ + auto stmt = db.prepareStatement ("SELECT 1"); + auto result = stmt.readBlob ("no_such_table", "no_such_col", 1, [] (int) -> void* + { + return nullptr; + }); + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (result.getErrorMessage().isNotEmpty()); +} + +TEST (SqliteDatabaseResultTests, ReadBlobOnNullConnectionHasErrorMessage) +{ + SqliteDatabase closed; + auto stmt = closed.prepareStatement ("SELECT 1"); + auto result = stmt.readBlob ("t", "data", 1, [] (int) -> void* + { + return nullptr; + }); + EXPECT_FALSE (result.wasOk()); + EXPECT_EQ ("Statement has no database connection", result.getErrorMessage()); +} + +//============================================================================== +// Variadic prepareStatement — success paths +//============================================================================== + +TEST_F (SqliteDatabaseTests, PrepareWithSingleIntArg) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (42)"); + + auto result = db.prepareStatement ("SELECT v FROM t WHERE v = ?", 42); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_ROW, result.getReference().step()); + EXPECT_EQ (42, result.getReference().columnInt (0)); +} + +TEST_F (SqliteDatabaseTests, PrepareWithSingleInt64Arg) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + const int64_t big = 9'000'000'000LL; + db.executeQuery ("INSERT INTO t VALUES (" + String (big) + ")"); + + auto result = db.prepareStatement ("SELECT v FROM t WHERE v = ?", big); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_ROW, result.getReference().step()); + EXPECT_EQ (big, result.getReference().columnInt64 (0)); +} + +TEST_F (SqliteDatabaseTests, PrepareWithSingleDoubleArg) +{ + db.executeQuery ("CREATE TABLE t (v REAL)"); + db.executeQuery ("INSERT INTO t VALUES (2.718)"); + + auto result = db.prepareStatement ("SELECT v FROM t WHERE v > ?", 2.0); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_ROW, result.getReference().step()); + EXPECT_DOUBLE_EQ (2.718, result.getReference().columnDouble (0)); +} + +TEST_F (SqliteDatabaseTests, PrepareWithSingleFloatArgPromotedToDouble) +{ + db.executeQuery ("CREATE TABLE t (v REAL)"); + db.executeQuery ("INSERT INTO t VALUES (1.0)"); + + auto result = db.prepareStatement ("SELECT v FROM t WHERE v < ?", 2.0f); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_ROW, result.getReference().step()); +} + +TEST_F (SqliteDatabaseTests, PrepareWithSingleBoolArg) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (1)"); + + auto result = db.prepareStatement ("SELECT v FROM t WHERE v = ?", true); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_ROW, result.getReference().step()); + EXPECT_TRUE (result.getReference().columnBool (0)); +} + +TEST_F (SqliteDatabaseTests, PrepareWithStringLiteralArg) +{ + db.executeQuery ("CREATE TABLE t (v TEXT)"); + db.executeQuery ("INSERT INTO t VALUES ('hello')"); + + auto result = db.prepareStatement ("SELECT v FROM t WHERE v = ?", "hello"); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_ROW, result.getReference().step()); + EXPECT_EQ ("hello", result.getReference().columnText (0)); +} + +TEST_F (SqliteDatabaseTests, PrepareWithStringArg) +{ + db.executeQuery ("CREATE TABLE t (v TEXT)"); + db.executeQuery ("INSERT INTO t VALUES ('world')"); + + const String text = "world"; + auto result = db.prepareStatement ("SELECT v FROM t WHERE v = ?", text); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_ROW, result.getReference().step()); + EXPECT_EQ ("world", result.getReference().columnText (0)); +} + +TEST_F (SqliteDatabaseTests, PrepareWithFileArg) +{ + db.executeQuery ("CREATE TABLE t (v TEXT)"); + const File f ("/tmp/test.db"); + db.executeQuery ("INSERT INTO t VALUES ('" + f.getFullPathName() + "')"); + + auto result = db.prepareStatement ("SELECT v FROM t WHERE v = ?", f); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_ROW, result.getReference().step()); + EXPECT_EQ (f.getFullPathName(), result.getReference().columnFile (0).getFullPathName()); +} + +TEST_F (SqliteDatabaseTests, PrepareWithNullptrArgBindsNull) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + { + auto result = db.prepareStatement ("INSERT INTO t VALUES (?)", nullptr); + ASSERT_TRUE (result.wasOk()); + result.getReference().step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnOptionalInt (0).has_value()); +} + +TEST_F (SqliteDatabaseTests, PrepareWithNulloptArgBindsNull) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + { + auto result = db.prepareStatement ("INSERT INTO t VALUES (?)", std::nullopt); + ASSERT_TRUE (result.wasOk()); + result.getReference().step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_FALSE (sel.columnOptionalInt (0).has_value()); +} + +TEST_F (SqliteDatabaseTests, PrepareWithBlobArg) +{ + db.executeQuery ("CREATE TABLE t (v BLOB)"); + const std::vector data = { 0xDE, 0xAD, 0xBE, 0xEF }; + { + auto result = db.prepareStatement ("INSERT INTO t VALUES (?)", + Span (data.data(), data.size())); + ASSERT_TRUE (result.wasOk()); + result.getReference().step(); + } + + auto sel = db.prepareStatement ("SELECT v FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + const auto blob = sel.columnBlob (0); + ASSERT_EQ (4u, blob.size()); + EXPECT_EQ (0, std::memcmp (data.data(), blob.data(), 4)); +} + +TEST_F (SqliteDatabaseTests, PrepareWithSmallIntegralTypesWidenedToInt) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (7)"); + + const int16_t small = 7; + auto result = db.prepareStatement ("SELECT v FROM t WHERE v = ?", small); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_ROW, result.getReference().step()); +} + +TEST_F (SqliteDatabaseTests, PrepareWithUnsignedInt) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (100)"); + + const unsigned int u = 100u; + auto result = db.prepareStatement ("SELECT v FROM t WHERE v = ?", u); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_ROW, result.getReference().step()); +} + +TEST_F (SqliteDatabaseTests, PrepareWithMultipleArgs) +{ + db.executeQuery ("CREATE TABLE t (id INTEGER, name TEXT, score REAL)"); + { + auto result = db.prepareStatement ("INSERT INTO t VALUES (?, ?, ?)", 1, "alice", 9.5); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_DONE, result.getReference().step()); + } + + auto sel = db.prepareStatement ("SELECT id, name, score FROM t WHERE id = ?", 1); + ASSERT_TRUE (sel.wasOk()); + ASSERT_EQ (SQLITE_ROW, sel.getReference().step()); + EXPECT_EQ (1, sel.getReference().columnInt (0)); + EXPECT_EQ ("alice", sel.getReference().columnText (1)); + EXPECT_DOUBLE_EQ (9.5, sel.getReference().columnDouble (2)); +} + +TEST_F (SqliteDatabaseTests, PrepareWithMixedTypesInsertAndQuery) +{ + db.executeQuery ("CREATE TABLE kv (key TEXT, ival INTEGER, rval REAL, bval INTEGER)"); + { + auto result = db.prepareStatement ("INSERT INTO kv VALUES (?, ?, ?, ?)", + "row1", + int64_t { 999 }, + 1.23, + true); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_DONE, result.getReference().step()); + } + + auto sel = db.prepareStatement ("SELECT key, ival, rval, bval FROM kv WHERE key = ?", "row1"); + ASSERT_TRUE (sel.wasOk()); + ASSERT_EQ (SQLITE_ROW, sel.getReference().step()); + EXPECT_EQ ("row1", sel.getReference().columnText (0)); + EXPECT_EQ (999, sel.getReference().columnInt64 (1)); + EXPECT_DOUBLE_EQ (1.23, sel.getReference().columnDouble (2)); + EXPECT_TRUE (sel.getReference().columnBool (3)); +} + +TEST_F (SqliteDatabaseTests, PrepareVariadicInsideTransaction) +{ + db.executeQuery ("CREATE TABLE t (id INTEGER, val TEXT)"); + + { + auto tx = db.beginTransaction(); + for (int i = 1; i <= 3; ++i) + { + auto result = db.prepareStatement ("INSERT INTO t VALUES (?, ?)", i, "v" + String (i)); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_DONE, result.getReference().step()); + } + } // commits + + auto sel = db.prepareStatement ("SELECT COUNT(*) FROM t"); + ASSERT_EQ (SQLITE_ROW, sel.step()); + EXPECT_EQ (3, sel.columnInt (0)); +} + +TEST_F (SqliteDatabaseTests, PrepareVariadicResultCanBeMovedOut) +{ + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (77)"); + + auto result = db.prepareStatement ("SELECT v FROM t WHERE v = ?", 77); + ASSERT_TRUE (result.wasOk()); + + SqliteDatabase::Statement stmt = std::move (result).getValue(); + EXPECT_TRUE (stmt.isValid()); + EXPECT_EQ (SQLITE_ROW, stmt.step()); + EXPECT_EQ (77, stmt.columnInt (0)); +} + +//============================================================================== +// Variadic prepareStatement — failure paths +//============================================================================== + +TEST_F (SqliteDatabaseTests, PrepareVariadicBadSqlFails) +{ + auto result = db.prepareStatement ("NOT VALID SQL !!!", 1, 2, 3); + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (result.getErrorMessage().isNotEmpty()); +} + +TEST_F (SqliteDatabaseTests, PrepareVariadicTooManyArgsFails) +{ + // SQL has 1 placeholder but we supply 3 args — caught by the parameter-count check. + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + auto result = db.prepareStatement ("INSERT INTO t VALUES (?)", 1, 2, 3); + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (result.getErrorMessage().containsIgnoreCase ("Expected 1")); +} + +TEST_F (SqliteDatabaseTests, PrepareVariadicTooFewArgsFails) +{ + // SQL has 3 placeholders but we supply only 1 arg — caught by the parameter-count check. + db.executeQuery ("CREATE TABLE t (a INTEGER, b INTEGER, c INTEGER)"); + auto result = db.prepareStatement ("INSERT INTO t VALUES (?, ?, ?)", 42); + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (result.getErrorMessage().containsIgnoreCase ("Expected 3")); +} + +TEST_F (SqliteDatabaseTests, PrepareVariadicExactArgCountSucceeds) +{ + db.executeQuery ("CREATE TABLE t (a INTEGER, b TEXT)"); + auto result = db.prepareStatement ("INSERT INTO t VALUES (?, ?)", 1, "one"); + EXPECT_TRUE (result.wasOk()); + EXPECT_EQ (SQLITE_DONE, result.getReference().step()); +} + +TEST (SqliteDatabaseVariadicTests, PrepareVariadicOnClosedDatabaseFails) +{ + SqliteDatabase closed; + auto result = closed.prepareStatement ("SELECT 1", 42); + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (result.getErrorMessage().isNotEmpty()); +} + +//============================================================================== +// vacuumToFile +//============================================================================== + +TEST (SqliteDatabaseVacuumTests, VacuumToFileOnClosedDatabaseFails) +{ + SqliteDatabase closed; + File dest = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_vacuum_closed_" + String::toHexString (Random::getSystemRandom().nextInt()) + ".db"); + + EXPECT_FALSE (closed.vacuumToFile (dest)); + dest.deleteFile(); +} + +TEST (SqliteDatabaseVacuumTests, VacuumInMemoryDatabaseToFile) +{ + SqliteDatabase db; + EXPECT_TRUE (db.openInMemory()); + + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (1)"); + db.executeQuery ("INSERT INTO t VALUES (2)"); + + File dest = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_vacuum_" + String::toHexString (Random::getSystemRandom().nextInt()) + ".db"); + + EXPECT_TRUE (db.vacuumToFile (dest)); + EXPECT_TRUE (dest.existsAsFile()); + + // Reopen the vacuumed file and verify the data is intact. + { + SqliteDatabase copy; + EXPECT_TRUE (copy.open (dest)); + + std::vector values; + copy.executeQuery ("SELECT v FROM t ORDER BY v", + [&] (int, char** argv, char**) + { + values.push_back (std::stoi (argv[0])); + return true; + }); + + ASSERT_EQ (2u, values.size()); + EXPECT_EQ (1, values[0]); + EXPECT_EQ (2, values[1]); + } + + dest.deleteFile(); +} + +TEST (SqliteDatabaseVacuumTests, VacuumOverwritesExistingFile) +{ + SqliteDatabase db; + EXPECT_TRUE (db.openInMemory()); + db.executeQuery ("CREATE TABLE t (v INTEGER)"); + db.executeQuery ("INSERT INTO t VALUES (42)"); + + File dest = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_vacuum_overwrite_" + String::toHexString (Random::getSystemRandom().nextInt()) + ".db"); + + // Create the destination file first so we exercise the overwrite path. + EXPECT_TRUE (db.vacuumToFile (dest)); + EXPECT_TRUE (dest.existsAsFile()); + + db.executeQuery ("INSERT INTO t VALUES (99)"); + EXPECT_TRUE (db.vacuumToFile (dest)); + + SqliteDatabase copy; + EXPECT_TRUE (copy.open (dest)); + + std::vector values; + copy.executeQuery ("SELECT v FROM t ORDER BY v", + [&] (int, char** argv, char**) + { + values.push_back (std::stoi (argv[0])); + return true; + }); + + ASSERT_EQ (2u, values.size()); + EXPECT_EQ (42, values[0]); + EXPECT_EQ (99, values[1]); + + dest.deleteFile(); +} + +#endif // YUP_MODULE_AVAILABLE_sqlite3_library diff --git a/thirdparty/sqlite3_library/sqlite3_library.c b/thirdparty/sqlite3_library/sqlite3_library.c new file mode 100644 index 000000000..31a3f45cc --- /dev/null +++ b/thirdparty/sqlite3_library/sqlite3_library.c @@ -0,0 +1,31 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#if __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wshorten-64-to-32" +#endif + +#include + +#if __clang__ +#pragma clang diagnostic pop +#endif diff --git a/thirdparty/sqlite3_library/sqlite3_library.h b/thirdparty/sqlite3_library/sqlite3_library.h new file mode 100644 index 000000000..0d64d90e9 --- /dev/null +++ b/thirdparty/sqlite3_library/sqlite3_library.h @@ -0,0 +1,45 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +/* + ============================================================================== + + BEGIN_YUP_MODULE_DECLARATION + + ID: sqlite3_library + vendor: D. Richard Hipp & Others + version: 3.53.1 + name: SQLite3 Library + description: SQLite is a C library that provides a lightweight, disk-based database. + website: https://www.sqlite.org + upstream: https://sqlite.org/2026/sqlite-amalgamation-3530100.zip + license: BSD + + defines: + + END_YUP_MODULE_DECLARATION + + ============================================================================== +*/ + +#pragma once + +#include From ea928bd047303cb50acc162ef27ca61554b86994 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 19 May 2026 17:01:32 +0200 Subject: [PATCH 2/4] Enable tests --- tests/yup_core.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/yup_core.cpp b/tests/yup_core.cpp index 49250a9fb..4be76f061 100644 --- a/tests/yup_core.cpp +++ b/tests/yup_core.cpp @@ -84,6 +84,7 @@ #include "yup_core/yup_Span.cpp" #include "yup_core/yup_SparseSet.cpp" #include "yup_core/yup_SpinLock.cpp" +#include "yup_core/yup_SqliteDatabase.cpp" #include "yup_core/yup_StatisticsAccumulator.cpp" #include "yup_core/yup_String.cpp" #include "yup_core/yup_StringArray.cpp" From f2f44979918b01f4d60f838ee2b11b1b38c54521 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 19 May 2026 17:25:51 +0200 Subject: [PATCH 3/4] Fix tests on windows --- tests/yup_core/yup_SqliteDatabase.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/yup_core/yup_SqliteDatabase.cpp b/tests/yup_core/yup_SqliteDatabase.cpp index 0fc3063b9..b8af8294d 100644 --- a/tests/yup_core/yup_SqliteDatabase.cpp +++ b/tests/yup_core/yup_SqliteDatabase.cpp @@ -626,12 +626,19 @@ TEST_F (SqliteDatabaseTests, ColumnOptionalFileNullopt) TEST_F (SqliteDatabaseTests, ColumnOptionalFileValue) { db.executeQuery ("CREATE TABLE t (v TEXT)"); - db.executeQuery ("INSERT INTO t VALUES ('/tmp/foo.db')"); + + const File file = File::getSpecialLocation (File::tempDirectory).getChildFile ("foo.db"); + + { + auto ins = db.prepareStatement ("INSERT INTO t VALUES (?)"); + EXPECT_TRUE (ins.bindText (1, file.getFullPathName()).wasOk()); + EXPECT_EQ (SQLITE_DONE, ins.step()); + } auto sel = db.prepareStatement ("SELECT v FROM t"); ASSERT_EQ (SQLITE_ROW, sel.step()); ASSERT_TRUE (sel.columnOptionalFile (0).has_value()); - EXPECT_EQ ("/tmp/foo.db", sel.columnOptionalFile (0)->getFullPathName()); + EXPECT_EQ (file.getFullPathName(), sel.columnOptionalFile (0)->getFullPathName()); } TEST_F (SqliteDatabaseTests, ColumnOptionalBlobNullopt) From 494c234f88e792627e5f8b6e0d613703a40c7fe6 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 19 May 2026 22:02:17 +0200 Subject: [PATCH 4/4] Change license from BSD to Public Domain --- thirdparty/sqlite3_library/sqlite3_library.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thirdparty/sqlite3_library/sqlite3_library.h b/thirdparty/sqlite3_library/sqlite3_library.h index 0d64d90e9..6540ce639 100644 --- a/thirdparty/sqlite3_library/sqlite3_library.h +++ b/thirdparty/sqlite3_library/sqlite3_library.h @@ -31,7 +31,7 @@ description: SQLite is a C library that provides a lightweight, disk-based database. website: https://www.sqlite.org upstream: https://sqlite.org/2026/sqlite-amalgamation-3530100.zip - license: BSD + license: Public Domain defines: