diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 6db9ca44..8c7766ac 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -5939,6 +5939,15 @@ UNSIGNED32 AdsAppendRecord(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { + if (st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = st->conn->append_blank(st); + if (!r) return fail(r.error()); + return ok(); + } +#endif #if defined(OPENADS_WITH_FIREBIRD) if (auto* ft = get_firebird_table(hTable)) { if (ft->conn == nullptr) @@ -6002,6 +6011,15 @@ UNSIGNED32 AdsWriteRecord(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { + if (st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = st->conn->flush_record(st); + if (!r) return fail(r.error()); + return ok(); + } +#endif #if defined(OPENADS_WITH_FIREBIRD) if (auto* ft = get_firebird_table(hTable)) { if (ft->conn == nullptr) @@ -6102,6 +6120,15 @@ UNSIGNED32 AdsDeleteRecord(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { + if (st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = st->conn->delete_record(st); + if (!r) return fail(r.error()); + return ok(); + } +#endif #if defined(OPENADS_WITH_FIREBIRD) if (auto* ft = get_firebird_table(hTable)) { if (ft->conn == nullptr) @@ -6251,6 +6278,20 @@ UNSIGNED32 AdsSetString(ADSHANDLE hTable, UNSIGNED8* pucField, if (!r) return fail(r.error()); return ok(); } +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string fname(reinterpret_cast(pucField)); + std::string val; + if (pucValue != nullptr && ulLen > 0) + val.assign(reinterpret_cast(pucValue), ulLen); + auto r = st->conn->set_field(st, fname, val); + if (!r) return fail(r.error()); + return ok(); + } +#endif #if defined(OPENADS_WITH_FIREBIRD) if (auto* ft = get_firebird_table(hTable)) { if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); diff --git a/src/sql_backend/sqlite_connection.cpp b/src/sql_backend/sqlite_connection.cpp index eee5b2b0..10a87fd7 100644 --- a/src/sql_backend/sqlite_connection.cpp +++ b/src/sql_backend/sqlite_connection.cpp @@ -650,4 +650,189 @@ SqliteConnection::run_sql(const std::string& sql) { #endif } +#if defined(OPENADS_WITH_SQLITE) +namespace { +std::string quote_ident_sqlite(const std::string& name) { + std::string out = "\""; + for (char c : name) { if (c == '"') out += '"'; out += c; } + out += '"'; + return out; +} +} // namespace +#endif + +util::Result SqliteConnection::append_blank(SqliteTable* tbl) { +#if defined(OPENADS_WITH_SQLITE) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid sqlite append", ""}; + } + if (!tbl->fields_cached) { + if (auto d = describe_table_impl(impl_->db, tbl); !d) return d.error(); + } + tbl->staging_row.assign(tbl->fields.size(), std::string{}); + tbl->staging_nulls.assign(tbl->fields.size(), true); + tbl->pending_append = true; + tbl->row_dirty = true; + tbl->row_valid = true; + tbl->positioned = true; + return util::Result{}; +#else + (void)tbl; + return util::Error{5004, 0, "sqlite backend disabled", ""}; +#endif +} + +util::Result SqliteConnection::set_field( + SqliteTable* tbl, const std::string& field_name, const std::string& value) { +#if defined(OPENADS_WITH_SQLITE) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid sqlite set_field", ""}; + } + if (!tbl->row_valid && !tbl->pending_append) { + return util::Error{5026, 0, "no current record", ""}; + } + if (!tbl->fields_cached) return util::Error{5001, 0, "schema not cached", ""}; + const std::size_t idx = field_index_ci(*tbl, field_name); + if (idx == static_cast(-1)) { + return util::Error{5063, 0, "column not found", field_name}; + } + if (!tbl->row_dirty && !tbl->pending_append) { + tbl->staging_row = tbl->current_row; + tbl->staging_nulls = tbl->current_nulls; + } + if (tbl->staging_row.size() < tbl->fields.size()) { + tbl->staging_row.resize(tbl->fields.size()); + tbl->staging_nulls.resize(tbl->fields.size(), true); + } + tbl->staging_row[idx] = value; + tbl->staging_nulls[idx] = false; + tbl->row_dirty = true; + return util::Result{}; +#else + (void)tbl; (void)field_name; (void)value; + return util::Error{5004, 0, "sqlite backend disabled", ""}; +#endif +} + +util::Result SqliteConnection::flush_record(SqliteTable* tbl) { +#if defined(OPENADS_WITH_SQLITE) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid sqlite flush", ""}; + } + if (!tbl->row_dirty && !tbl->pending_append) return util::Result{}; + if (!tbl->fields_cached) return util::Error{5001, 0, "schema not cached", ""}; + sqlite3* db = impl_->db; + + if (tbl->pending_append) { + std::string cols, marks; + std::vector bound; + for (std::size_t i = 0; i < tbl->fields.size(); ++i) { + if (i < tbl->staging_nulls.size() && tbl->staging_nulls[i]) continue; + if (!bound.empty()) { cols += ", "; marks += ", "; } + cols += quote_ident_sqlite(tbl->fields[i].name); + marks += "?"; + bound.push_back(i); + } + if (bound.empty()) { + return util::Error{5001, 0, "insert has no columns", tbl->name}; + } + const std::string sql = "INSERT INTO " + quote_ident_sqlite(tbl->name) + + " (" + cols + ") VALUES (" + marks + ")"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, sql.c_str(), static_cast(sql.size()), + &stmt, nullptr) != SQLITE_OK) { + return sqlite_error(db, "prepare insert"); + } + for (std::size_t k = 0; k < bound.size(); ++k) { + const std::string& v = tbl->staging_row[bound[k]]; + sqlite3_bind_text(stmt, static_cast(k + 1), v.c_str(), + static_cast(v.size()), SQLITE_TRANSIENT); + } + const int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) return sqlite_error(db, "exec insert"); + + const std::int64_t new_rowid = sqlite3_last_insert_rowid(db); + tbl->pending_append = false; + tbl->row_dirty = false; + if (auto r = load_rowids(db, tbl); !r) return r.error(); + return position_at_rowid(db, tbl, new_rowid); + } + + // UPDATE the current row, keyed by its rowid. + if (!tbl->positioned || tbl->pos >= tbl->rowids.size()) { + return util::Error{5026, 0, "no current record", ""}; + } + const std::int64_t rowid = tbl->rowids[tbl->pos]; + std::string set_clause; + std::vector bound; + for (std::size_t i = 0; i < tbl->fields.size(); ++i) { + if (i >= tbl->staging_row.size()) continue; + if (!bound.empty()) set_clause += ", "; + set_clause += quote_ident_sqlite(tbl->fields[i].name) + " = ?"; + bound.push_back(i); + } + if (bound.empty()) { tbl->row_dirty = false; return util::Result{}; } + const std::string sql = "UPDATE " + quote_ident_sqlite(tbl->name) + + " SET " + set_clause + " WHERE rowid = ?"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, sql.c_str(), static_cast(sql.size()), + &stmt, nullptr) != SQLITE_OK) { + return sqlite_error(db, "prepare update"); + } + int p = 1; + for (std::size_t i : bound) { + if (i < tbl->staging_nulls.size() && tbl->staging_nulls[i]) { + sqlite3_bind_null(stmt, p++); + } else { + const std::string& v = tbl->staging_row[i]; + sqlite3_bind_text(stmt, p++, v.c_str(), + static_cast(v.size()), SQLITE_TRANSIENT); + } + } + sqlite3_bind_int64(stmt, p, rowid); + const int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) return sqlite_error(db, "exec update"); + + tbl->row_dirty = false; + return position_at_rowid(db, tbl, rowid); +#else + (void)tbl; + return util::Error{5004, 0, "sqlite backend disabled", ""}; +#endif +} + +util::Result SqliteConnection::delete_record(SqliteTable* tbl) { +#if defined(OPENADS_WITH_SQLITE) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid sqlite delete", ""}; + } + if (tbl->pending_append || !tbl->positioned || + tbl->pos >= tbl->rowids.size()) { + return util::Error{5026, 0, "no current record", ""}; + } + sqlite3* db = impl_->db; + const std::int64_t rowid = tbl->rowids[tbl->pos]; + const std::string sql = "DELETE FROM " + quote_ident_sqlite(tbl->name) + + " WHERE rowid = ?"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, sql.c_str(), static_cast(sql.size()), + &stmt, nullptr) != SQLITE_OK) { + return sqlite_error(db, "prepare delete"); + } + sqlite3_bind_int64(stmt, 1, rowid); + const int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) return sqlite_error(db, "exec delete"); + + tbl->row_dirty = false; + tbl->pending_append = false; + return load_rowids(db, tbl); +#else + (void)tbl; + return util::Error{5004, 0, "sqlite backend disabled", ""}; +#endif +} + } // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/sqlite_connection.h b/src/sql_backend/sqlite_connection.h index c9514c70..38d3387f 100644 --- a/src/sql_backend/sqlite_connection.h +++ b/src/sql_backend/sqlite_connection.h @@ -41,6 +41,17 @@ class SqliteConnection { util::Result goto_bottom(SqliteTable* tbl); util::Result skip(SqliteTable* tbl, std::int32_t step); + // Navigational write (mirrors MariaConnection/FirebirdConnection): + // append_blank stages a blank row, set_field stages one column, + // flush_record emits an INSERT (pending_append) or a rowid-keyed UPDATE, + // delete_record a rowid-keyed DELETE. SQLite rowid is the implicit key. + util::Result append_blank(SqliteTable* tbl); + util::Result set_field(SqliteTable* tbl, + const std::string& field_name, + const std::string& value); + util::Result flush_record(SqliteTable* tbl); + util::Result delete_record(SqliteTable* tbl); + // Tier-2 push-down: install (where non-empty) or clear (where empty) a SQL // WHERE fragment and reload the rowid list so navigation walks only the // matching rows. `where` must be a trusted, already-translated SQL boolean diff --git a/src/sql_backend/sqlite_table.h b/src/sql_backend/sqlite_table.h index 6e0272c3..1c04d870 100644 --- a/src/sql_backend/sqlite_table.h +++ b/src/sql_backend/sqlite_table.h @@ -48,6 +48,14 @@ struct SqliteTable { bool last_seek_found = false; + // Write staging (mirrors MariaTable/FirebirdTable): append_blank/set_field + // stage column values; flush_record emits an INSERT (pending_append) or a + // rowid-keyed UPDATE; delete_record a rowid-keyed DELETE. + std::vector staging_row; + std::vector staging_nulls; + bool pending_append = false; + bool row_dirty = false; + // Result-set cursor mode (AdsExecuteSQLDirect SELECT passthrough): rows are // materialized in memory instead of fetched per-rowid from a base table, so // navigation serves `current_row` straight from `result_rows[pos]`. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5ef800e5..9188032a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,6 +74,7 @@ add_executable(openads_unit_tests unit/abi_aggregate_sqlite_test.cpp unit/abi_plus_sqlite_seek_test.cpp unit/abi_plus_sqlite_passthrough_test.cpp + unit/abi_plus_sqlite_write_test.cpp unit/abi_plus_sqlcipher_read_test.cpp unit/lsn_map_test.cpp unit/index_expr_test.cpp diff --git a/tests/unit/abi_plus_sqlite_write_test.cpp b/tests/unit/abi_plus_sqlite_write_test.cpp new file mode 100644 index 00000000..fd4ae8dd --- /dev/null +++ b/tests/unit/abi_plus_sqlite_write_test.cpp @@ -0,0 +1,127 @@ +// tests/unit/abi_plus_sqlite_write_test.cpp +// Navigational write on a SQLite-backed table: AdsAppendRecord + +// AdsSetString + AdsWriteRecord + AdsDeleteRecord. SQLite is in-process +// (vendored amalgamation), so this test is fully self-contained — it seeds a +// temp .db via the sqlite3 C API, then drives writes purely through the ACE +// ABI, mirroring the MariaDB/Postgres/Firebird navigational-write contract. +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +#if defined(OPENADS_WITH_SQLITE) +#include + +namespace { + +void seed_clientes(const fs::path& db_path) { + sqlite3* db = nullptr; + REQUIRE(sqlite3_open(db_path.string().c_str(), &db) == SQLITE_OK); + auto exec = [&](const char* sql) { + char* err = nullptr; + if (sqlite3_exec(db, sql, nullptr, nullptr, &err) != SQLITE_OK) { + const std::string msg = err ? err : "exec failed"; + if (err) sqlite3_free(err); + FAIL(msg); + } + }; + exec("CREATE TABLE clientes (" + "id INTEGER PRIMARY KEY, nome TEXT, saldo REAL)"); + exec("INSERT INTO clientes (id, nome, saldo) VALUES " + "(1,'Ana',10.5), (2,'Bob',NULL), (3,'Cid',0.0)"); + sqlite3_close(db); +} + +std::string rtrim(std::string s) { + while (!s.empty() && s.back() == ' ') s.pop_back(); + return s; +} + +std::string field_str(ADSHANDLE hTable, const char* name) { + UNSIGNED8 fld[32]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[256] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + +void set_str(ADSHANDLE hTable, const char* field, const char* value) { + UNSIGNED8 f[64]; + std::memcpy(f, field, std::strlen(field) + 1); + UNSIGNED8 v[256]; + std::memcpy(v, value, std::strlen(value) + 1); + REQUIRE(AdsSetString(hTable, f, v, + static_cast(std::strlen(value))) == 0); +} + +UNSIGNED32 row_count(ADSHANDLE hTable) { + UNSIGNED32 count = 0; + REQUIRE(AdsGetRecordCount(hTable, 0, &count) == 0); + return count; +} + +struct WriteFixture { + fs::path dir; + ADSHANDLE hConn = 0; + ADSHANDLE hTable = 0; + void open() { + dir = fs::temp_directory_path() / "openads_write_sqlite"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + seed_clientes(dir / "clientes.db"); + const std::string uri = "sqlite://" + (dir / "clientes.db").string(); + std::vector srv(uri.size() + 1); + std::memcpy(srv.data(), uri.c_str(), uri.size() + 1); + REQUIRE(AdsConnect60(srv.data(), ADS_LOCAL_SERVER, nullptr, nullptr, 0, + &hConn) == 0); + UNSIGNED8 tname[32] = "clientes"; + REQUIRE(AdsOpenTable(hConn, tname, tname, ADS_DEFAULT, 0, 0, 0, + ADS_DEFAULT, &hTable) == 0); + } + ~WriteFixture() { + if (hTable) AdsCloseTable(hTable); + if (hConn) AdsDisconnect(hConn); + std::error_code ec; + fs::remove_all(dir, ec); + } +}; + +} // namespace + +TEST_CASE("ABI: sqlite AdsAppendRecord + AdsSetString + AdsWriteRecord + AdsDeleteRecord") { + WriteFixture fx; + fx.open(); + + CHECK(row_count(fx.hTable) == 3); + + // Append a new row through the navigational ABI. + REQUIRE(AdsAppendRecord(fx.hTable) == 0); + set_str(fx.hTable, "id", "99"); + set_str(fx.hTable, "nome", "Dan"); + set_str(fx.hTable, "saldo", "42.5"); + REQUIRE(AdsWriteRecord(fx.hTable) == 0); + CHECK(row_count(fx.hTable) == 4); + + REQUIRE(AdsGotoBottom(fx.hTable) == 0); + CHECK(rtrim(field_str(fx.hTable, "nome")) == "Dan"); + + // Update the current row. + set_str(fx.hTable, "nome", "DanX"); + REQUIRE(AdsWriteRecord(fx.hTable) == 0); + REQUIRE(AdsGotoBottom(fx.hTable) == 0); + CHECK(rtrim(field_str(fx.hTable, "nome")) == "DanX"); + + // Delete the current row. + REQUIRE(AdsDeleteRecord(fx.hTable) == 0); + CHECK(row_count(fx.hTable) == 3); +} + +#endif // OPENADS_WITH_SQLITE