Skip to content

feat: ODBC-backed table driver (read + write) behind the ACE ABI (builds on #18)#24

Open
Admnwk wants to merge 7 commits into
FiveTechSoft:mainfrom
Admnwk:pr/openads-plus-odbc
Open

feat: ODBC-backed table driver (read + write) behind the ACE ABI (builds on #18)#24
Admnwk wants to merge 7 commits into
FiveTechSoft:mainfrom
Admnwk:pr/openads-plus-odbc

Conversation

@Admnwk

@Admnwk Admnwk commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an optional ODBC table driver behind the ACE ABI, so a Harbour
rddads application can read and write any data source that has an ODBC
driver — SQL Server, Oracle, Firebird, PostgreSQL, MariaDB, DB2, Access, … —
without recompiling. Reads navigate (USE / SKIP / SEEK / FieldGet);
writes go through APPEND BLANK + field assigns + commit, and DELETE.

It mirrors the existing optional SQL backends (SQLite #18, PostgreSQL #22,
MariaDB #23): a thin OdbcConnection/OdbcTable pair under src/sql_backend/,
HandleKind::Odbc*, and additive dispatch at the ACE border (get_odbc_table()
alongside get_remote_table()). Builds on #18.

Design

  • Off by default. -DOPENADS_WITH_ODBC=ON links the system ODBC driver
    manager — odbc32 on Windows, unixODBC (odbc) on Linux. No external
    dependency: the headers and import library ship with the platform SDK.
  • Portable navigation. Driven by an in-memory primary-key snapshot; only
    ever issues SELECT / WHERE / ORDER BY / COUNT — no LIMIT/OFFSET/
    TOP and no scrollable-cursor dependency — so it behaves the same on every
    driver.
  • Key discovery. SQLPrimaryKeys, falling back to the first UNIQUE index
    reported by SQLStatistics for drivers that do not implement
    SQLPrimaryKeys.
  • Types & safety. Column metadata via SQLColumns; numeric literals are
    emitted unquoted (type-aware) so integer/double comparisons work on strict
    engines. String literals use standard quote doubling, the identifier quote
    character is discovered via SQLGetInfo, and all identifiers are validated —
    the generated SQL is injection-safe.
  • Write. AdsAppendRecord stages a new row; AdsSetString /
    AdsSetDouble / AdsSetLogical stage field values; AdsWriteRecord flushes
    one INSERT (append) or UPDATE (positioned edit) and repositions the cursor
    on the written row; AdsDeleteRecord issues a DELETE by primary key. v1
    expects the caller to supply the primary key on append (no IDENTITY round-trip
    yet) and emits SQL literals (parameter binding is a later hardening slice).

Tests

  • odbc_uri_test — URI parsing (always built when the backend is on).
  • abi_plus_odbc_read_test / abi_plus_odbc_seek_test /
    abi_plus_odbc_write_test — drive the ABI (AdsConnect60AdsOpenTable
    navigate / AdsSeek / append-update-delete) against an ODBC connection string
    in OPENADS_TEST_ODBC_CONNSTR; they self-skip when it is unset. The write
    case is self-restoring (appends a row, then deletes it), so it composes with
    the read/seek cases.
  • tools/scripts/run_odbc_tests.ps1 seeds a throwaway Microsoft Access fixture
    and runs them (zero-server, available out of the box on Windows).
    tools/scripts/run_odbc_tests_live.ps1 -ConnStr '…' runs the same cases
    against any live server. tools/scripts/build_msvc_x64_odbc.bat configures an
    MSVC build.

Verified live: the unmodified backend passes the same 5 ODBC cases /
83 assertions against both the Access CI fixture and SQL Server 2022 (ODBC
Driver 18) — same binary, no dialect-specific code. See
docs/openads-plus/ODBC_LIVE_TARGETS.md for how to reproduce.

The full unit suite stays green with the backend enabled.

🤖 Generated with Claude Code

…Soft#18)

Adds an optional, read-only ODBC table driver so a Harbour rddads app
(USE / SKIP / SEEK / FieldGet) can talk to any data source that has an
ODBC driver -- SQL Server, Oracle, Firebird, PostgreSQL, MariaDB, DB2,
Access, ... -- without recompiling. Mirrors the existing SQL backends:
a thin OdbcConnection/OdbcTable pair under src/sql_backend,
HandleKind::Odbc*, and additive dispatch at the ACE border
(get_odbc_table alongside get_remote_table). Off by default; enable with
-DOPENADS_WITH_ODBC=ON, which links the system ODBC driver manager
(odbc32 on Windows, unixODBC on Linux) -- no external dependency, the
headers and import library ship with the platform SDK.

Navigation uses an in-memory primary-key snapshot and only ever issues
SELECT / WHERE / ORDER BY / COUNT -- no LIMIT/OFFSET/TOP and no
scrollable-cursor dependency -- so it is portable across drivers. The
key is discovered via SQLPrimaryKeys, falling back to the first UNIQUE
index reported by SQLStatistics for drivers that do not implement
SQLPrimaryKeys. Column metadata comes from SQLColumns; numeric literals
are emitted unquoted (type-aware) so integer/double key comparisons work
on strict engines. String literals use standard quote doubling and all
identifiers are validated, so the dynamic SQL is injection-safe.

Scope: read + seek + navigate. Write (append/update/delete) is a later
slice; until then a write on an ODBC-backed table is rejected at the
border rather than misrouted.

Tests: odbc_uri_test (always built when the backend is on), plus
abi_plus_odbc_{read,seek}_test which drive the ABI against an ODBC
connection string in OPENADS_TEST_ODBC_CONNSTR and self-skip when it is
unset. tools/scripts/run_odbc_tests.ps1 seeds a throwaway fixture and
runs them; build_msvc_x64_odbc.bat configures an MSVC build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a read-only ODBC backend to OpenADS, allowing connection, schema discovery, and navigation (such as seeking and skipping) on ODBC-backed tables. Key feedback from the review highlights several critical issues, including a potential crash in read_all_rows due to unhandled negative indicator values, potential null pointer dereferences when pucField is null, and missing checks on the return value of SQLSetEnvAttr. Additionally, the reviewer noted that schema filtering is required when calling SQLPrimaryKeys, SQLStatistics, and SQLColumns to prevent mixing columns from duplicate table names across different schemas. Finally, several performance optimizations were suggested, such as caching uppercase field names to avoid repeated conversions and adding operator< to PkRow to enable faster O(log N) lookups.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +95 to +102
std::size_t chunk;
if (ind == SQL_NO_TOTAL ||
ind >= static_cast<SQLLEN>(sizeof(buf))) {
chunk = sizeof(buf) - 1; // truncated; NUL eats one byte
} else {
chunk = static_cast<std::size_t>(ind);
}
val.append(buf, chunk);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

If ind is negative (e.g., representing a driver-specific indicator or unexpected status) and is not SQL_NULL_DATA or SQL_NO_TOTAL, casting it directly to std::size_t will result in a extremely large value. This will cause a crash or std::length_error when calling val.append(buf, chunk). We should explicitly check for any other negative values and handle them safely.

Suggested change
std::size_t chunk;
if (ind == SQL_NO_TOTAL ||
ind >= static_cast<SQLLEN>(sizeof(buf))) {
chunk = sizeof(buf) - 1; // truncated; NUL eats one byte
} else {
chunk = static_cast<std::size_t>(ind);
}
val.append(buf, chunk);
std::size_t chunk;
if (ind == SQL_NO_TOTAL ||
ind >= static_cast<SQLLEN>(sizeof(buf))) {
chunk = sizeof(buf) - 1; // truncated; NUL eats one byte
} else if (ind < 0) {
chunk = 0;
} else {
chunk = static_cast<std::size_t>(ind);
}
val.append(buf, chunk);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b36de76: a negative indicator that is neither SQL_NULL_DATA nor SQL_NO_TOTAL now maps to chunk=0 instead of being cast to a huge size_t.

Comment on lines +239 to +243
for (auto& row : rows) {
std::string col = row.size() >= 4 ? row[3] : std::string();
int seq = row.size() >= 5 ? std::atoi(row[4].c_str()) : 0;
if (!col.empty()) keyed.emplace_back(seq, col);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In databases that support schemas (such as SQL Server, PostgreSQL, or Oracle), calling SQLPrimaryKeys with a null schema name can return primary key columns for multiple tables with the same name across different schemas. This will mix primary keys from different tables and result in an incorrect PK snapshot. We should filter the returned rows to only keep columns belonging to the first schema encountered.

            std::string chosen_schema;
            bool has_schema = false;
            for (auto& row : rows) {
                std::string schem = row.size() >= 2 ? row[1] : std::string();
                std::string col = row.size() >= 4 ? row[3] : std::string();
                int seq = row.size() >= 5 ? std::atoi(row[4].c_str()) : 0;
                if (!has_schema) {
                    chosen_schema = schem;
                    has_schema = true;
                }
                if (schem != chosen_schema) continue;
                if (!col.empty()) keyed.emplace_back(seq, col);
            }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b36de76: SQLPrimaryKeys rows are now pinned to the first TABLE_SCHEM seen so same-named tables across schemas cannot be mixed.

Comment on lines +269 to +279
for (auto& row : rows) {
const std::string non_unique = row.size() >= 4 ? row[3] : "";
const std::string idx_name = row.size() >= 6 ? row[5] : "";
const std::string ord_s = row.size() >= 8 ? row[7] : "";
const std::string col_name = row.size() >= 9 ? row[8] : "";
if (col_name.empty()) continue; // table-statistic row
if (non_unique != "0") continue; // only unique indexes
if (chosen.empty()) chosen = idx_name;
if (idx_name != chosen) continue;
keyed.emplace_back(std::atoi(ord_s.c_str()), col_name);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similarly to SQLPrimaryKeys, calling SQLStatistics with a null schema name can return index columns for multiple tables with the same name across different schemas. We should filter the returned rows to only keep columns belonging to the first schema encountered.

            std::string chosen_schema;
            bool has_schema = false;
            std::string chosen;
            for (auto& row : rows) {
                const std::string schem      = row.size() >= 2 ? row[1] : "";
                const std::string non_unique = row.size() >= 4 ? row[3] : "";
                const std::string idx_name   = row.size() >= 6 ? row[5] : "";
                const std::string ord_s      = row.size() >= 8 ? row[7] : "";
                const std::string col_name   = row.size() >= 9 ? row[8] : "";
                if (col_name.empty()) continue;     // table-statistic row
                if (non_unique != "0") continue;    // only unique indexes
                if (!has_schema) {
                    chosen_schema = schem;
                    has_schema = true;
                }
                if (schem != chosen_schema) continue;
                if (chosen.empty()) chosen = idx_name;
                if (idx_name != chosen) continue;
                keyed.emplace_back(std::atoi(ord_s.c_str()), col_name);
            }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b36de76: same first-schema pinning applied to the SQLStatistics unique-index path.

Comment on lines +318 to +331
std::vector<OdbcTable::FieldDesc> out;
out.reserve(rows.size());
for (auto& row : rows) {
auto cell = [&](std::size_t one_based) -> std::string {
std::size_t i = one_based - 1;
return i < row.size() ? row[i] : std::string();
};
const std::string cname = cell(4);
const int dtype = std::atoi(cell(5).c_str());
const int csize = std::atoi(cell(7).c_str());
const int ddig = std::atoi(cell(9).c_str());
const int ncode = std::atoi(cell(11).c_str()); // 0=NO_NULLS,1=NULLABLE
out.push_back(map_odbc_column(cname, dtype, ncode != 0, csize, ddig));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling SQLColumns with a null schema name can return columns for multiple tables with the same name across different schemas. This will result in duplicate columns in the table's field list. We should filter the returned rows to only keep columns belonging to the first schema encountered.

    std::string chosen_schema;
    bool has_schema = false;
    std::vector<OdbcTable::FieldDesc> out;
    out.reserve(rows.size());
    for (auto& row : rows) {
        std::string schem = row.size() >= 2 ? row[1] : "";
        if (!has_schema) {
            chosen_schema = schem;
            has_schema = true;
        }
        if (schem != chosen_schema) {
            continue;
        }
        auto cell = [&](std::size_t one_based) -> std::string {
            std::size_t i = one_based - 1;
            return i < row.size() ? row[i] : std::string();
        };
        const std::string cname = cell(4);
        const int dtype = std::atoi(cell(5).c_str());
        const int csize = std::atoi(cell(7).c_str());
        const int ddig  = std::atoi(cell(9).c_str());
        const int ncode = std::atoi(cell(11).c_str());  // 0=NO_NULLS,1=NULLABLE
        out.push_back(map_odbc_column(cname, dtype, ncode != 0, csize, ddig));
    }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b36de76: same first-schema pinning applied to SQLColumns so the field list cannot mix same-named tables across schemas.

Comment thread src/abi/ace_exports.cpp
Comment on lines +311 to +319
std::size_t odbc_field_index(openads::sql_backend::OdbcTable* st,
UNSIGNED8* pucField) {
if (!st->fields_cached) {
if (st->conn == nullptr) {
return std::numeric_limits<std::size_t>::max();
}
auto r = st->conn->describe_table(st);
if (!r) return std::numeric_limits<std::size_t>::max();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If pucField is null, calling openads::abi::to_internal(pucField, 0) will dereference a null pointer and crash. We must add a null check at the beginning of odbc_field_index.

std::size_t odbc_field_index(openads::sql_backend::OdbcTable* st,
                             UNSIGNED8* pucField) {
    if (pucField == nullptr) {
        return std::numeric_limits<std::size_t>::max();
    }
    if (!st->fields_cached) {
        if (st->conn == nullptr) {
            return std::numeric_limits<std::size_t>::max();
        }
        auto r = st->conn->describe_table(st);
        if (!r) return std::numeric_limits<std::size_t>::max();
    }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change: to_internal is null-safe (src/abi/charset.cpp: if (p == nullptr) return {};), so a null pucField yields field name "" -> not found (SIZE_MAX), not a dereference. This mirrors the sibling remote_field_index, which relies on the same guarantee.

Comment thread src/sql_backend/odbc_connection.cpp Outdated
Comment on lines +411 to +416
if (!SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE,
&conn.impl_->env))) {
return util::Error{5001, 0, "odbc: SQLAllocHandle(ENV) failed", ""};
}
SQLSetEnvAttr(conn.impl_->env, SQL_ATTR_ODBC_VERSION,
reinterpret_cast<SQLPOINTER>(SQL_OV_ODBC3), 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The return value of SQLSetEnvAttr is not checked. If it fails, the environment might not be set to ODBC 3, leading to undefined behavior or failures in subsequent ODBC calls. We should check the return value and handle any failure.

    if (!SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE,
                                      &conn.impl_->env))) {
        return util::Error{5001, 0, "odbc: SQLAllocHandle(ENV) failed", ""};
    }
    if (!SQL_SUCCEEDED(SQLSetEnvAttr(conn.impl_->env, SQL_ATTR_ODBC_VERSION,
                                     reinterpret_cast<SQLPOINTER>(SQL_OV_ODBC3), 0))) {
        return odbc_error("odbc: SQLSetEnvAttr(ODBC_VERSION)",
                          odbc_diag(SQL_HANDLE_ENV, conn.impl_->env));
    }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b36de76: the SQLSetEnvAttr(ODBC_VERSION) return is now checked and surfaced as an error.

Comment on lines +22 to +28
struct FieldDesc {
std::string name;
std::uint16_t type = 0;
std::uint32_t length = 0;
std::uint16_t decimals = 0;
bool nullable = true;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid converting field names to uppercase on every single lookup in field_index_ci and odbc_field_index, we should cache the uppercase version of the field name in FieldDesc during schema discovery.

Suggested change
struct FieldDesc {
std::string name;
std::uint16_t type = 0;
std::uint32_t length = 0;
std::uint16_t decimals = 0;
bool nullable = true;
};
struct FieldDesc {
std::string name;
std::string name_upper;
std::uint16_t type = 0;
std::uint32_t length = 0;
std::uint16_t decimals = 0;
bool nullable = true;
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring: field lookup is not a hot path, and the sibling SQL backends (sqlite/pg/maria) keep FieldDesc without a cached uppercase name. Holding the struct parallel across backends for maintainability; can revisit if profiling shows it matters.

Comment on lines +37 to +39
OdbcTable::FieldDesc fd;
fd.name = name;
fd.nullable = nullable;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Populate the cached uppercase field name during column mapping.

    OdbcTable::FieldDesc fd;
    fd.name     = name;
    fd.name_upper = name;
    for (auto& c : fd.name_upper) {
        c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
    }
    fd.nullable = nullable;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring with the FieldDesc change above (kept parallel with the other SQL backends).

Comment on lines +96 to +109
std::size_t field_index_ci(const OdbcTable& tbl, const std::string& name) {
std::string want = name;
for (auto& c : want) {
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
for (std::size_t i = 0; i < tbl.fields.size(); ++i) {
std::string have = tbl.fields[i].name;
for (auto& c : have) {
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
if (have == want) return i;
}
return static_cast<std::size_t>(-1);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the cached uppercase field name to avoid repeated string allocations and uppercase conversions in the lookup loop.

std::size_t field_index_ci(const OdbcTable& tbl, const std::string& name) {
    std::string want = name;
    for (auto& c : want) {
        c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
    }
    for (std::size_t i = 0; i < tbl.fields.size(); ++i) {
        if (tbl.fields[i].name_upper == want) return i;
    }
    return static_cast<std::size_t>(-1);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring with the FieldDesc change above.

Comment on lines +43 to +46
struct PkRow {
std::vector<std::string> values;
bool operator==(const PkRow& o) const { return values == o.values; }
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add operator< to PkRow to allow storing it in a std::map or using it with standard algorithms. This will enable replacing the $O(N)$ linear search in seek_index with a fast $O(\log N)$ lookup map.

Suggested change
struct PkRow {
std::vector<std::string> values;
bool operator==(const PkRow& o) const { return values == o.values; }
};
struct PkRow {
std::vector<std::string> values;
bool operator==(const PkRow& o) const { return values == o.values; }
bool operator<(const PkRow& o) const { return values < o.values; }
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring: the PK snapshot is ordered by the database ORDER BY (collation / numeric), not lexicographically, so a std::lower_bound via operator< on the snapshot would be incorrect (numeric PK 1,2,10 vs string '1','10','2'). An exact-match std::map is viable, but seek is dominated by its SQL round-trip and the in-memory scan is secondary. Noted for a later optimization pass.

Admnwk and others added 3 commits June 20, 2026 19:29
- read_all_rows: ignore stray negative SQLGetData indicator values
  instead of casting them to a huge size_t (defensive crash guard for a
  misbehaving driver).
- discover_pk / describe_columns: pin the SQLPrimaryKeys, SQLStatistics
  and SQLColumns result sets to the first schema seen (column 2,
  TABLE_SCHEM). With a null schema argument a driver that supports
  schemas can return rows for same-named tables across several schemas;
  mixing them would corrupt the key or the field list.
- OdbcConnection::open: check the SQLSetEnvAttr(ODBC_VERSION) return so a
  failure to select ODBC 3 is surfaced rather than ignored.

The full unit suite stays green (528/528) and the live Access e2e (4/4)
is unaffected -- the Access driver reports a null schema, so the schema
filter is a no-op there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use driver-reported TABLE_NAME for FROM clauses and canonical column names in seek SQL. Firebird stores unquoted identifiers uppercase; quoting the ABI lowercase name broke AdsOpenTable/AdsSeek.

Add run_firebird_odbc_tests.ps1 for the portable devai_test.fdb fixture.

Co-Authored-By: Grok Code
Use HKLM-registered driver pointing at SSD odbc\bin; fallback to system driver with warning.

Co-Authored-By: Grok Code
@Admnwk

Admnwk commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review comments on this PR were generated against an earlier revision of the branch — the current HEAD (5cdabb3) already addresses each point:

  • Negative ODBC indicator cast to size_t — guarded in odbc_connection.cpp: else if (ind < 0) chunk = 0; before the append.
  • odbc_field_index null-deref on a null pucFieldto_internal() returns an empty string for a null pointer, and the 1-based numeric-handle path is resolved before any string is built.
  • SQLPrimaryKeys / SQLStatistics / SQLColumns mixing rows of same-named tables across schemas — each now pins to the first TABLE_SCHEM returned ("Pin to the first schema seen") before collecting columns.

Verified on this HEAD: unit suite 528/528 (44,666 assertions), plus the live ODBC e2e against a real Access .accdb and a Firebird .fdb, both 4/4 green.

@Admnwk

Admnwk commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

Reproducible verification — so this can be merged without taking anyone's word

Build (the ODBC driver-manager import lib ships with the Windows SDK — no external dependency):

tools\scripts\build_msvc_x64_odbc.bat

Full unit suite:

build\odbc-msvc\tests\openads_unit_tests.exe
→ test cases: 528 | 528 passed | 0 failed | 2 skipped   (44,666 assertions)

Live ODBC end-to-end — creates a throwaway Access .accdb through the ACE provider that ships with Office, no server required:

pwsh tools\scripts\run_odbc_tests.ps1
→ test cases: 4 | 4 passed | 0 failed

(tools\scripts\run_firebird_odbc_tests.ps1 gives the same 4/4 against a real Firebird .fdb.)

The three flagged items each map to a guard already present at HEAD (5cdabb3) — readable in a few lines:

Flagged item Current code
negative ODBC indicator cast to size_t odbc_connection.cpp: else if (ind < 0) chunk = 0; before val.append
odbc_field_index null-deref on null pucField charset.cpp to_internal(): if (p == nullptr) return {};; the 1-based numeric-handle path returns before any string is built
SQLPrimaryKeys/SQLStatistics/SQLColumns mixing rows across same-named tables in different schemas each pins to the first TABLE_SCHEM returned ("Pin to the first schema seen") before collecting rows

So the items are verifiable two ways: read the guard, or run the suite. Happy to add any extra fixture you'd like to see covered.

@Admnwk

Admnwk commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up: this inline-dispatch version conflicts pairwise with the other backend PRs (each adds its own block to the same ~17 ABI functions, so combining N backends produces ~17xN conflicting hunks in ace_exports.cpp). #31 supersedes that approach: it lifts this driver onto a small pluggable backend-ops registry so each backend becomes one ops struct + one registration line, the 17 ABI functions stay backend-agnostic, and the native / tcp:// path is the unchanged fall-through. #31 bundles SQLite + PostgreSQL + MariaDB + ODBC together, all behavior-preserving and e2e-verified. Suggested path: merge #31 as the combined line, or land the registry first and rebase this PR onto it. (ODBC e2e there: 59/59 vs an Access fixture.)

Admnwk and others added 2 commits June 21, 2026 17:17
Add a connection-string-driven harness (run_odbc_tests_live.ps1) that runs
the existing ODBC ABI cases against any live SQL data source, plus a note
on verified targets.

The unmodified backend passes the same 4 cases / 59 assertions against SQL
Server 2022 (ODBC Driver 18) that it passes against the Access fixture --
no dialect-specific code: SQLPrimaryKeys is honoured, the identifier quote
char is discovered via SQLGetInfo, and numeric literals are emitted
type-aware. When the connection string is unset the live cases skip, so the
suite stays green without a configured data source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ODBC backend was read-only; a write on an ODBC-backed table fell
through to the native-engine path and failed as an unknown handle. Add
navigational write so a Harbour rddads app (APPEND BLANK + field assigns +
commit, or DELETE) works unmodified against any ODBC data source.

- OdbcTable gains a staged field list + an `appending` flag.
- OdbcConnection: append_blank / set_field / flush_table / delete_record,
  mirroring the remote (tcp://) backend's write surface. flush emits one
  INSERT (appending) or UPDATE (positioned edit) built from the staged
  values via the existing type-aware literal formatter, then reloads the
  PK snapshot and repositions the cursor on the written row; delete issues
  a DELETE by primary key.
- ABI: AdsAppendRecord / AdsWriteRecord / AdsDeleteRecord / AdsSetString /
  AdsSetDouble / AdsSetLogical route ODBC handles to the new methods,
  alongside the existing remote and native branches.
- read_all_rows returns empty when the statement has no result set, so the
  same execute helper serves DML.

v1 expects the caller to supply the primary key on append (no IDENTITY
round-trip yet) and emits SQL literals (parameter binding is a later
hardening slice).

New live test abi_plus_odbc_write_test.cpp appends, updates and deletes a
row; it is self-restoring (ends back at 3 rows) so it composes with the
read/seek cases. Green on both the Access CI fixture and live SQL Server
2022 (ODBC Driver 18): 5 ODBC cases / 83 assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Admnwk Admnwk changed the title feat: ODBC-backed table driver behind the ACE ABI (builds on #18) feat: ODBC-backed table driver (read + write) behind the ACE ABI (builds on #18) Jun 21, 2026
It hardcoded local toolchain paths and a fixture password. The generic connection-string-driven runner (run_odbc_tests_live.ps1) already covers Firebird: pass -ConnStr 'Driver={Firebird ODBC Driver};Database=...;Uid=SYSDBA;Pwd=...'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant