From 046c5f7e332e709a4a26074fb40117e24f7ef064 Mon Sep 17 00:00:00 2001 From: Admnwk <220553748+Admnwk@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:32:21 -0300 Subject: [PATCH] feat(mssql): navigational write (append/set/flush/delete) for the native TDS backend The native MS SQL Server backend was read-only ("write not available in v1"); AdsAppendRecord / AdsSetString / AdsWriteRecord / AdsDeleteRecord returned AE_FUNCTION_NOT_AVAILABLE. This adds navigational write so the native TDS backend matches the MariaDB / PostgreSQL / Firebird / ODBC write contract. - MssqlTable: retain the owning connection + table name; discover the primary-key columns at open (INFORMATION_SCHEMA); add a staging buffer. - MssqlConnection: implement append_blank / set_field / flush_record / delete_record. flush emits an INSERT (pending append) or a PK-keyed UPDATE; delete a PK-keyed DELETE, via the existing TDS query() path. Values are emitted as N'...' literals (SQL Server implicit-converts to the column type); the result set is re-fetched after each write so record count and navigation stay consistent. Write requires a discovered primary key. - ace_exports: dispatch the four ABI write entry points to the MSSQL backend (the three former "not available in v1" stubs + a new AdsSetString branch). - New live test (abi_plus_mssql_write_test, gated on OPENADS_TEST_MSSQL_CONNSTR): seeds a CLIENTES table via the connection, then drives append/update/delete through the ACE ABI. Testing (SQL Server 2025, native TDS over TLS, OPENADS_WITH_MSSQL=ON): new write test 27/27 assertions pass; /WX (-Werror) clean, MSVC x64. Note: the pre-existing live read test (abi_plus_mssql_read_test) asserts that NVARCHAR values are blank-padded to their declared width; against this server they come back unpadded. That is a read-path matter independent of this write change (it only surfaces now that a live server makes the gated read test run); it is not touched here. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/abi/ace_exports.cpp | 41 ++++- src/sql_backend/mssql_connection.cpp | 215 +++++++++++++++++++++++ src/sql_backend/mssql_connection.h | 15 +- src/sql_backend/mssql_table.cpp | 37 +++- src/sql_backend/mssql_table.h | 13 ++ tests/CMakeLists.txt | 1 + tests/unit/abi_plus_mssql_write_test.cpp | 133 ++++++++++++++ 7 files changed, 444 insertions(+), 11 deletions(-) create mode 100644 tests/unit/abi_plus_mssql_write_test.cpp diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 6db9ca44..2180699a 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -5976,9 +5976,12 @@ UNSIGNED32 AdsAppendRecord(ADSHANDLE hTable) { } #endif #if defined(OPENADS_WITH_MSSQL) - if (get_mssql_table(hTable)) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "MssqlTable: write not available in v1"); + if (auto* st = get_mssql_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 Table* t = get_table(hTable); @@ -6039,9 +6042,12 @@ UNSIGNED32 AdsWriteRecord(ADSHANDLE hTable) { } #endif #if defined(OPENADS_WITH_MSSQL) - if (get_mssql_table(hTable)) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "MssqlTable: write not available in v1"); + if (auto* st = get_mssql_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 Table* t = get_table(hTable); @@ -6139,9 +6145,12 @@ UNSIGNED32 AdsDeleteRecord(ADSHANDLE hTable) { } #endif #if defined(OPENADS_WITH_MSSQL) - if (get_mssql_table(hTable)) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "MssqlTable: write not available in v1"); + if (auto* st = get_mssql_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 Table* t = get_table(hTable); @@ -6306,6 +6315,20 @@ UNSIGNED32 AdsSetString(ADSHANDLE hTable, UNSIGNED8* pucField, if (!r) return fail(r.error()); return ok(); } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* st = get_mssql_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 Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); diff --git a/src/sql_backend/mssql_connection.cpp b/src/sql_backend/mssql_connection.cpp index 0524fb0f..fd6441c3 100644 --- a/src/sql_backend/mssql_connection.cpp +++ b/src/sql_backend/mssql_connection.cpp @@ -3,14 +3,68 @@ #if defined(OPENADS_WITH_MSSQL) #include "openads/error.h" +#include "sql_backend/mssql_table.h" #include "sql_backend/mssql_uri.h" #include "sql_backend/tds_protocol.h" +#include +#include #include #include +#include namespace openads::sql_backend { +namespace { + +// [name] with ']' doubled — safe SQL Server identifier quoting. +std::string quote_ident(const std::string& name) { + std::string out = "["; + for (char c : name) { if (c == ']') out += ']'; out += c; } + out += ']'; + return out; +} + +// N'...' literal with '\'' doubled. All staged values are bound as Unicode +// string literals; SQL Server implicit-converts to the target column type. +std::string quote_lit(const std::string& v) { + std::string out = "N'"; + for (char c : v) { if (c == '\'') out += '\''; out += c; } + out += '\''; + return out; +} + +std::size_t col_index_ci(const MssqlTable& t, const std::string& name) { + for (std::size_t i = 0; i < t.data.columns.size(); ++i) { + const std::string& cn = t.data.columns[i].name; + if (cn.size() != name.size()) continue; + bool eq = true; + for (std::size_t k = 0; k < cn.size(); ++k) { + if (std::tolower(static_cast(cn[k])) != + std::tolower(static_cast(name[k]))) { eq = false; break; } + } + if (eq) return i; + } + return static_cast(-1); +} + +// Build "[pk1] = N'v1' AND [pk2] = N'v2'" from a result row's PK cells. +std::string pk_where(const MssqlTable& t, + const std::vector& row) { + std::string w; + bool any = false; + for (std::size_t i : t.pk_cols) { + if (any) w += " AND "; + w += quote_ident(t.data.columns[i].name); + if (i < row.size() && row[i].is_null) w += " IS NULL"; + else w += " = " + quote_lit(i < row.size() ? row[i].value : std::string{}); + any = true; + } + return w; +} + +} // namespace + struct MssqlConnection::Impl { TdsTlsChannel channel; bool authenticated = false; @@ -122,6 +176,167 @@ util::Result MssqlConnection::query(const std::string& sql) { return qr; } +// --------------------------------------------------------------------------- +// Navigational write +// --------------------------------------------------------------------------- + +namespace { +// Re-run SELECT * and replace the table's buffered result (so record_count +// and navigation reflect the write). Resets the cursor to BOF. +util::Result refetch(MssqlConnection& c, MssqlTable* tbl) { + auto qr = c.query("SELECT * FROM " + quote_ident(tbl->table_name)); + if (!qr) return qr.error(); + if (!qr.value().ok) { + return util::Error{static_cast(qr.value().error_number), + 0, qr.value().message, ""}; + } + tbl->data = std::move(qr).value(); + tbl->pos = 0; + tbl->bof = true; + tbl->eof = tbl->data.rows.empty(); + return util::Result{}; +} +} // namespace + +util::Result MssqlConnection::append_blank(MssqlTable* tbl) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mssql append", ""}; + } + const std::size_t n = tbl->data.columns.size(); + tbl->staging_row.assign(n, std::string{}); + tbl->staging_nulls.assign(n, true); + tbl->pending_append = true; + tbl->row_dirty = true; + return util::Result{}; +} + +util::Result MssqlConnection::set_field( + MssqlTable* tbl, const std::string& field_name, const std::string& value) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mssql set_field", ""}; + } + const std::size_t idx = col_index_ci(*tbl, field_name); + if (idx == static_cast(-1)) { + return util::Error{5063, 0, "column not found", field_name}; + } + const std::size_t n = tbl->data.columns.size(); + if (!tbl->row_dirty && !tbl->pending_append) { + // Seed staging from the current row so unchanged columns survive UPDATE. + tbl->staging_row.assign(n, std::string{}); + tbl->staging_nulls.assign(n, true); + if (tbl->pos < tbl->data.rows.size()) { + const auto& row = tbl->data.rows[tbl->pos]; + for (std::size_t i = 0; i < n && i < row.size(); ++i) { + tbl->staging_row[i] = row[i].value; + tbl->staging_nulls[i] = row[i].is_null; + } + } + } + if (tbl->staging_row.size() < n) { + tbl->staging_row.resize(n); + tbl->staging_nulls.resize(n, true); + } + tbl->staging_row[idx] = value; + tbl->staging_nulls[idx] = false; + tbl->row_dirty = true; + return util::Result{}; +} + +util::Result MssqlConnection::flush_record(MssqlTable* tbl) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mssql flush", ""}; + } + if (!tbl->row_dirty && !tbl->pending_append) return util::Result{}; + const std::size_t n = tbl->data.columns.size(); + + if (tbl->pending_append) { + std::string cols, vals; + bool any = false; + for (std::size_t i = 0; i < n; ++i) { + if (i < tbl->staging_nulls.size() && tbl->staging_nulls[i]) continue; + if (any) { cols += ", "; vals += ", "; } + cols += quote_ident(tbl->data.columns[i].name); + vals += quote_lit(tbl->staging_row[i]); + any = true; + } + if (!any) { + return util::Error{5001, 0, "insert has no columns", tbl->table_name}; + } + const std::string sqlq = "INSERT INTO " + quote_ident(tbl->table_name) + + " (" + cols + ") VALUES (" + vals + ")"; + auto r = query(sqlq); + if (!r) return r.error(); + if (!r.value().ok) { + return util::Error{static_cast(r.value().error_number), + 0, r.value().message, sqlq}; + } + tbl->pending_append = false; + tbl->row_dirty = false; + return refetch(*this, tbl); + } + + // UPDATE the current row, keyed on its primary key. + if (tbl->pk_cols.empty()) { + return util::Error{5004, 0, "mssql update requires a primary key", + tbl->table_name}; + } + if (tbl->pos >= tbl->data.rows.size()) { + return util::Error{5026, 0, "no current record", ""}; + } + const std::string where = pk_where(*tbl, tbl->data.rows[tbl->pos]); + std::vector is_pk(n, false); + for (std::size_t i : tbl->pk_cols) if (i < n) is_pk[i] = true; + std::string setc; + bool any = false; + for (std::size_t i = 0; i < n; ++i) { + if (is_pk[i] || i >= tbl->staging_row.size()) continue; + if (any) setc += ", "; + setc += quote_ident(tbl->data.columns[i].name) + " = " + + ((i < tbl->staging_nulls.size() && tbl->staging_nulls[i]) + ? std::string("NULL") + : quote_lit(tbl->staging_row[i])); + any = true; + } + if (!any) { tbl->row_dirty = false; return refetch(*this, tbl); } + const std::string sqlq = "UPDATE " + quote_ident(tbl->table_name) + + " SET " + setc + " WHERE " + where; + auto r = query(sqlq); + if (!r) return r.error(); + if (!r.value().ok) { + return util::Error{static_cast(r.value().error_number), + 0, r.value().message, sqlq}; + } + tbl->row_dirty = false; + return refetch(*this, tbl); +} + +util::Result MssqlConnection::delete_record(MssqlTable* tbl) { + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid mssql delete", ""}; + } + if (tbl->pending_append) { + return util::Error{5026, 0, "no current record", ""}; + } + if (tbl->pk_cols.empty()) { + return util::Error{5004, 0, "mssql delete requires a primary key", + tbl->table_name}; + } + if (tbl->pos >= tbl->data.rows.size()) { + return util::Error{5026, 0, "no current record", ""}; + } + const std::string sqlq = "DELETE FROM " + quote_ident(tbl->table_name) + + " WHERE " + pk_where(*tbl, tbl->data.rows[tbl->pos]); + auto r = query(sqlq); + if (!r) return r.error(); + if (!r.value().ok) { + return util::Error{static_cast(r.value().error_number), + 0, r.value().message, sqlq}; + } + tbl->row_dirty = false; + tbl->pending_append = false; + return refetch(*this, tbl); +} + } // namespace openads::sql_backend #endif // defined(OPENADS_WITH_MSSQL) diff --git a/src/sql_backend/mssql_connection.h b/src/sql_backend/mssql_connection.h index 064d7fcc..97706064 100644 --- a/src/sql_backend/mssql_connection.h +++ b/src/sql_backend/mssql_connection.h @@ -18,7 +18,8 @@ namespace openads::sql_backend { -struct MssqlUri; // sql_backend/mssql_uri.h +struct MssqlUri; // sql_backend/mssql_uri.h +struct MssqlTable; // sql_backend/mssql_table.h class MssqlConnection { public: @@ -44,6 +45,18 @@ class MssqlConnection { // SQL text is backend-generated; NEVER put secrets or credentials in sql. util::Result query(const std::string& sql); + // Navigational write (mirrors the other SQL backends): append_blank stages + // a blank row, set_field stages one column, flush_record emits an INSERT + // (pending_append) or a PK-keyed UPDATE, delete_record a PK-keyed DELETE. + // The result set is re-fetched after each write so navigation/count stay + // consistent. Requires the table to have a discovered primary key. + util::Result append_blank(MssqlTable* tbl); + util::Result set_field(MssqlTable* tbl, + const std::string& field_name, + const std::string& value); + util::Result flush_record(MssqlTable* tbl); + util::Result delete_record(MssqlTable* tbl); + private: struct Impl; std::unique_ptr impl_; diff --git a/src/sql_backend/mssql_table.cpp b/src/sql_backend/mssql_table.cpp index 294462b0..9204dfe7 100644 --- a/src/sql_backend/mssql_table.cpp +++ b/src/sql_backend/mssql_table.cpp @@ -12,6 +12,7 @@ #include "sql_backend/sql_common.h" #include +#include #include #include @@ -158,7 +159,41 @@ MssqlTable::open(MssqlConnection& c, const std::string& table_name) { result.message, sql}; } - return from_result(std::move(result)); + auto t = from_result(std::move(result)); + t->conn = &c; + t->table_name = table_name; + + // Discover primary-key columns (best-effort; only writes need them). + // table_name passed is_safe_identifier above, so it is safe to inline. + auto ci_equal = [](const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + if (std::tolower(static_cast(a[i])) != + std::tolower(static_cast(b[i]))) return false; + } + return true; + }; + const std::string pk_sql = + "SELECT kcu.COLUMN_NAME " + "FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc " + "JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu " + " ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME " + " AND tc.TABLE_NAME = kcu.TABLE_NAME " + "WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' " + " AND tc.TABLE_NAME = '" + table_name + "' " + "ORDER BY kcu.ORDINAL_POSITION"; + if (auto pk = c.query(pk_sql); pk && pk.value().ok) { + for (const auto& row : pk.value().rows) { + if (row.empty()) continue; + for (std::size_t i = 0; i < t->data.columns.size(); ++i) { + if (ci_equal(t->data.columns[i].name, row[0].value)) { + t->pk_cols.push_back(i); + break; + } + } + } + } + return t; } std::unique_ptr MssqlTable::from_result(tds::QueryResult qr) { diff --git a/src/sql_backend/mssql_table.h b/src/sql_backend/mssql_table.h index 4a5849eb..501f1119 100644 --- a/src/sql_backend/mssql_table.h +++ b/src/sql_backend/mssql_table.h @@ -16,6 +16,7 @@ #include #include #include +#include namespace openads::sql_backend { @@ -35,6 +36,18 @@ struct MssqlTable { // Last seek result — always false (no seek in v1). bool last_found = false; + // -------------------------------------------------------------------- + // Navigational write state (set by MssqlTable::open; used by + // MssqlConnection::append_blank/set_field/flush_record/delete_record). + // -------------------------------------------------------------------- + MssqlConnection* conn = nullptr; // owning connection (for query) + std::string table_name; // for write SQL generation + std::vector pk_cols; // primary-key column indices + std::vector staging_row; // pending column values + std::vector staging_nulls; + bool pending_append = false; + bool row_dirty = false; + // -------------------------------------------------------------------- // Factory // -------------------------------------------------------------------- diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5ef800e5..42490193 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -266,6 +266,7 @@ if(OPENADS_WITH_MSSQL) target_sources(openads_unit_tests PRIVATE unit/abi_plus_mssql_connect_test.cpp unit/abi_plus_mssql_read_test.cpp + unit/abi_plus_mssql_write_test.cpp unit/tds_protocol_test.cpp unit/mssql_table_skip_test.cpp unit/mssql_uri_test.cpp diff --git a/tests/unit/abi_plus_mssql_write_test.cpp b/tests/unit/abi_plus_mssql_write_test.cpp new file mode 100644 index 00000000..2e974396 --- /dev/null +++ b/tests/unit/abi_plus_mssql_write_test.cpp @@ -0,0 +1,133 @@ +// Live navigational-write test for the native MS SQL Server (TDS) backend. +// Requires: OPENADS_WITH_MSSQL=ON. +// Runtime gate: OPENADS_TEST_MSSQL_CONNSTR (full mssql:// URI). When unset the +// test emits a MESSAGE and returns. Mirrors the MariaDB/Postgres/Firebird +// navigational-write contract: AdsAppendRecord + AdsSetString + AdsWriteRecord +// + AdsDeleteRecord, keyed on the table's primary key. +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include + +#if defined(OPENADS_WITH_MSSQL) + +namespace { + +const char* mssql_connstr() { + const char* v = std::getenv("OPENADS_TEST_MSSQL_CONNSTR"); + return (v != nullptr && v[0] != '\0') ? v : nullptr; +} + +ADSHANDLE connect_mssql() { + const char* cs = mssql_connstr(); + if (cs == nullptr) return 0; + std::vector srv(std::strlen(cs) + 1); + std::memcpy(srv.data(), cs, std::strlen(cs) + 1); + ADSHANDLE h = 0; + REQUIRE(AdsConnect60(srv.data(), ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &h) == 0); + return h; +} + +UNSIGNED32 exec_sql(ADSHANDLE hConn, const char* sql) { + ADSHANDLE hStmt = 0; + UNSIGNED32 rc = AdsCreateSQLStatement(hConn, &hStmt); + if (rc != 0) return rc; + std::vector b(std::strlen(sql) + 1); + std::memcpy(b.data(), sql, std::strlen(sql) + 1); + ADSHANDLE hCursor = 0; + rc = AdsExecuteSQLDirect(hStmt, b.data(), &hCursor); + AdsCloseSQLStatement(hStmt); + if (hCursor != 0) AdsCloseTable(hCursor); + return rc; +} + +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[128]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[512] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, ADS_NONE) == 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 c = 0; + REQUIRE(AdsGetRecordCount(hTable, ADS_RESPECTFILTERS, &c) == 0); + return c; +} + +} // namespace + +TEST_CASE("ABI: mssql AdsAppendRecord + AdsSetString + AdsWriteRecord + AdsDeleteRecord") { + const char* cs = mssql_connstr(); + if (cs == nullptr) { + MESSAGE("OPENADS_TEST_MSSQL_CONNSTR not set; skipping mssql live write test"); + return; + } + + ADSHANDLE hConn = connect_mssql(); + REQUIRE(hConn != 0); + + exec_sql(hConn, "IF OBJECT_ID('CLIENTES','U') IS NOT NULL DROP TABLE CLIENTES"); + REQUIRE(exec_sql(hConn, + "CREATE TABLE CLIENTES (" + " id INT PRIMARY KEY," + " nome NVARCHAR(64)," + " saldo FLOAT" + ")") == 0); + REQUIRE(exec_sql(hConn, + "INSERT INTO CLIENTES (id, nome, saldo) VALUES" + " (1, N'Ana', 10.5), (2, N'Bob', 0.0), (3, N'Cid', 1.0)") == 0); + + UNSIGNED8 tbl_name[32] = "CLIENTES"; + ADSHANDLE hTable = 0; + REQUIRE(AdsOpenTable(hConn, tbl_name, tbl_name, + ADS_DEFAULT, 0, 0, 0, ADS_DEFAULT, &hTable) == 0); + + CHECK(row_count(hTable) == 3); + + // Append a new row through the navigational ABI. + REQUIRE(AdsAppendRecord(hTable) == 0); + set_str(hTable, "id", "99"); + set_str(hTable, "nome", "Dan"); + set_str(hTable, "saldo", "42.5"); + REQUIRE(AdsWriteRecord(hTable) == 0); + CHECK(row_count(hTable) == 4); + + REQUIRE(AdsGotoBottom(hTable) == 0); + CHECK(rtrim(field_str(hTable, "nome")) == "Dan"); + + // Update the current row. + set_str(hTable, "nome", "DanX"); + REQUIRE(AdsWriteRecord(hTable) == 0); + REQUIRE(AdsGotoBottom(hTable) == 0); + CHECK(rtrim(field_str(hTable, "nome")) == "DanX"); + + // Delete the current row. + REQUIRE(AdsDeleteRecord(hTable) == 0); + CHECK(row_count(hTable) == 3); + + REQUIRE(AdsCloseTable(hTable) == 0); + REQUIRE(AdsDisconnect(hConn) == 0); +} + +#endif // OPENADS_WITH_MSSQL