From f5e5cc5420e8f58839cad75e71f135cb8d4215a3 Mon Sep 17 00:00:00 2001 From: autoantwort Date: Tue, 14 Jul 2026 03:17:24 +0200 Subject: [PATCH 1/3] add odbc connector by claude code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was built New connector under include/sqlpp23/odbc/, enabled with -DBUILD_ODBC_CONNECTOR=ON (uses CMake's built-in FindODBC, which found your Homebrew unixODBC 2.3.14). Your requirements, and how they're addressed: ODBC 3.8: the environment requests SQL_OV_ODBC3_80 at allocation (connection_handle.h). Errors are surfaced as sqlpp::odbc::exception carrying the full diagnostic-record chain, SQLSTATE, and native error code. Variant config: connection_config.h holds std::variant source — either a DSN (SQLConnect with name/user/password) or a full connection string (SQLDriverConnect, no prompt). Exactly one way to connect per config, dispatched with std::visit. Multi-row fetch: cursor_result.h uses an ODBC block cursor when possible — columns bound via SQLBindCol to column-wise arrays, SQL_ATTR_ROW_ARRAY_SIZE rows (config row_array_size, default 64) fetched per driver round trip. If any text/blob column is unbounded or exceeds max_bound_column_size (default 4096 bytes/row, text budgeted at 4 bytes/char for UTF-8), it transparently falls back to row-at-a-time streaming via SQLGetData with a growing buffer — the portable pattern that works on every driver (columns are read in increasing order). It also handles drivers that lower the requested array size (reads back the actual value) or reject block cursors entirely. Meaningful names: the result type is cursor_result_t ("block cursor" is the official ODBC term), not bind_result_t; helpers are execute_direct, prepare_statement, stream_value_if_required, release_cursor, etc. No single-caller _impl functions: connection.h only has private helpers with multiple callers (execute_direct ×5, prepare_statement ×5, run_prepared_command ×4, allocate_statement ×2); the _select/_insert/… entry points do their work inline. Other design points worth knowing: transactions use SQL_ATTR_AUTOCOMMIT + SQLEndTran (ODBC has no BEGIN), isolation levels map to SQL_ATTR_TXN_ISOLATION; date/time literals serialize as ODBC escape sequences ({d ...}, {t ...}, {ts ...}) and parameters as ?; statement handles are shared_ptrs so a result from a prepared statement keeps it alive, and the result's destructor closes/unbinds the cursor so the prepared statement can re-execute; there is deliberately no last_insert_id (nothing portable exists — documented in docs/connectors/odbc.md). The C++20 module interface sqlpp23.odbc.cppm mirrors the sqlite3 one. Tests Under tests/odbc/, all runnable offline: serialize: date/time/timestamp escape sequences, string escaping, ? parameter markers, full select statements (escaping needs no live driver, so SQLPP_COMPARE works against an unconnected connection). usage/ConnectionConfig: variant behavior, config equality, and a real driver-manager round trip — connecting to a nonexistent DSN and verifying the exception carries a SQLSTATE. usage/Statements: compiles every execution path (direct + prepared select/insert/update/delete, NULL parameters, transactions); it runs the statements for real if you ever set SQLPP_ODBC_CONNECTION_STRING. Limitations The BUILD_WITH_MODULES build can't be validated on this machine — the system AppleClang lacks clang-scan-deps, which breaks all module targets (core included), not just the new one. sqlpp::time values lose sub-second precision (ODBC's SQL_TIME_STRUCT/{t} escape have none); timestamps keep microseconds. This and other portability notes are in the docs page. --- CMakeLists.txt | 4 + README.md | 2 +- cmake/configs/Sqlpp23ODBCConfig.cmake | 2 + docs/connectors.md | 2 + docs/connectors/odbc.md | 106 +++ include/sqlpp23/odbc/cursor_result.h | 669 ++++++++++++++++++ include/sqlpp23/odbc/database/connection.h | 422 +++++++++++ .../sqlpp23/odbc/database/connection_config.h | 79 +++ .../sqlpp23/odbc/database/connection_handle.h | 174 +++++ .../sqlpp23/odbc/database/connection_pool.h | 35 + include/sqlpp23/odbc/database/exception.h | 56 ++ .../odbc/database/serializer_context.h | 53 ++ include/sqlpp23/odbc/detail/diagnostics.h | 93 +++ include/sqlpp23/odbc/odbc.h | 31 + include/sqlpp23/odbc/prepared_statement.h | 352 +++++++++ include/sqlpp23/odbc/to_sql_string.h | 71 ++ modules/sqlpp23.odbc.cppm | 53 ++ tests/CMakeLists.txt | 4 + tests/core/types/detail/get_last_if.cpp | 7 +- tests/include/sqlpp23/tests/odbc/all.h | 54 ++ .../sqlpp23/tests/odbc/make_test_connection.h | 76 ++ .../sqlpp23/tests/odbc/serialize_helpers.h | 49 ++ tests/include/sqlpp23/tests/odbc/tables.h | 209 ++++++ tests/include/sqlpp23/tests/odbc/tables.sql | 40 ++ tests/odbc/CMakeLists.txt | 28 + .../modules/sqlpp23.test.odbc.tables.cppm | 210 ++++++ tests/odbc/serialize/CMakeLists.txt | 26 + .../odbc/serialize/data_types/CMakeLists.txt | 30 + tests/odbc/serialize/data_types/date.cpp | 37 + tests/odbc/serialize/data_types/text.cpp | 37 + tests/odbc/serialize/data_types/time.cpp | 40 ++ tests/odbc/serialize/data_types/timestamp.cpp | 40 ++ tests/odbc/serialize/statement/CMakeLists.txt | 28 + tests/odbc/serialize/statement/parameter.cpp | 41 ++ tests/odbc/serialize/statement/select.cpp | 41 ++ tests/odbc/usage/CMakeLists.txt | 28 + tests/odbc/usage/ConnectionConfig.cpp | 73 ++ tests/odbc/usage/Statements.cpp | 113 +++ 38 files changed, 3408 insertions(+), 7 deletions(-) create mode 100644 cmake/configs/Sqlpp23ODBCConfig.cmake create mode 100644 docs/connectors/odbc.md create mode 100644 include/sqlpp23/odbc/cursor_result.h create mode 100644 include/sqlpp23/odbc/database/connection.h create mode 100644 include/sqlpp23/odbc/database/connection_config.h create mode 100644 include/sqlpp23/odbc/database/connection_handle.h create mode 100644 include/sqlpp23/odbc/database/connection_pool.h create mode 100644 include/sqlpp23/odbc/database/exception.h create mode 100644 include/sqlpp23/odbc/database/serializer_context.h create mode 100644 include/sqlpp23/odbc/detail/diagnostics.h create mode 100644 include/sqlpp23/odbc/odbc.h create mode 100644 include/sqlpp23/odbc/prepared_statement.h create mode 100644 include/sqlpp23/odbc/to_sql_string.h create mode 100644 modules/sqlpp23.odbc.cppm create mode 100644 tests/include/sqlpp23/tests/odbc/all.h create mode 100644 tests/include/sqlpp23/tests/odbc/make_test_connection.h create mode 100644 tests/include/sqlpp23/tests/odbc/serialize_helpers.h create mode 100644 tests/include/sqlpp23/tests/odbc/tables.h create mode 100644 tests/include/sqlpp23/tests/odbc/tables.sql create mode 100644 tests/odbc/CMakeLists.txt create mode 100644 tests/odbc/modules/sqlpp23.test.odbc.tables.cppm create mode 100644 tests/odbc/serialize/CMakeLists.txt create mode 100644 tests/odbc/serialize/data_types/CMakeLists.txt create mode 100644 tests/odbc/serialize/data_types/date.cpp create mode 100644 tests/odbc/serialize/data_types/text.cpp create mode 100644 tests/odbc/serialize/data_types/time.cpp create mode 100644 tests/odbc/serialize/data_types/timestamp.cpp create mode 100644 tests/odbc/serialize/statement/CMakeLists.txt create mode 100644 tests/odbc/serialize/statement/parameter.cpp create mode 100644 tests/odbc/serialize/statement/select.cpp create mode 100644 tests/odbc/usage/CMakeLists.txt create mode 100644 tests/odbc/usage/ConnectionConfig.cpp create mode 100644 tests/odbc/usage/Statements.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ee24804e..350cff96b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,7 @@ include(sqlpp23_build_targets) option(BUILD_MYSQL_CONNECTOR "Build MySQL Connector" OFF) option(BUILD_MARIADB_CONNECTOR "Build MariaDB Connector" OFF) +option(BUILD_ODBC_CONNECTOR "Build ODBC Connector" OFF) option(BUILD_POSTGRESQL_CONNECTOR "Build PostgreSQL Connector" OFF) option(BUILD_SQLITE3_CONNECTOR "Build SQLite3 Connector" OFF) option(BUILD_SQLCIPHER_CONNECTOR "Build SQLite3 Connector with SQLCipher" OFF) @@ -78,6 +79,9 @@ endif() if(BUILD_MARIADB_CONNECTOR) add_build_component(NAME MariaDB PACKAGE MariaDB DEPENDENCIES MariaDB::MariaDB HEADER_DIR mysql MODULE_INTERFACE sqlpp23.mysql.cppm) endif() +if(BUILD_ODBC_CONNECTOR) + add_build_component(NAME ODBC PACKAGE ODBC DEPENDENCIES ODBC::ODBC HEADER_DIR odbc MODULE_INTERFACE sqlpp23.odbc.cppm) +endif() if(BUILD_POSTGRESQL_CONNECTOR) add_build_component(NAME PostgreSQL PACKAGE PostgreSQL DEPENDENCIES PostgreSQL::PostgreSQL HEADER_DIR postgresql MODULE_INTERFACE sqlpp23.postgresql.cppm) endif() diff --git a/README.md b/README.md index 848355970..d3281bf70 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Connector libraries can inform you and your IDE of missing features at compile t They also interpret expressions specifically where needed. For example, the connector could use the `operator||` or the `concat` method for string concatenation without your being required to change the statement. -Connectors for MariaDB, MySQL, PostgreSQL, sqlite3, sqlcipher are included in this repository. +Connectors for MariaDB, MySQL, ODBC, PostgreSQL, sqlite3, sqlcipher are included in this repository. Documentation is found in [docs](/docs/README.md). diff --git a/cmake/configs/Sqlpp23ODBCConfig.cmake b/cmake/configs/Sqlpp23ODBCConfig.cmake new file mode 100644 index 000000000..94ca5188a --- /dev/null +++ b/cmake/configs/Sqlpp23ODBCConfig.cmake @@ -0,0 +1,2 @@ +include(CMakeFindDependencyMacro) +find_dependency(ODBC) diff --git a/docs/connectors.md b/docs/connectors.md index 819a133d3..4daea3da1 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -29,6 +29,8 @@ See the links below for details: [MySQL & MariaDB](/docs/connectors/mysql.md) +[ODBC](/docs/connectors/odbc.md) + [PostgreSQL](/docs/connectors/postgresql.md) [Sqlite3 and SQLCipher](/docs/connectors/sqlite3.md) diff --git a/docs/connectors/odbc.md b/docs/connectors/odbc.md new file mode 100644 index 000000000..f940b0777 --- /dev/null +++ b/docs/connectors/odbc.md @@ -0,0 +1,106 @@ +[**\< Connectors**](/docs/connectors.md) + +# ODBC connector + +The ODBC connector uses ODBC 3.8 and works with any database that provides an +ODBC driver (through a driver manager like unixODBC, iODBC, or the Windows +ODBC driver manager). + +Since ODBC only standardizes the API, not the SQL dialect, statements are +serialized in a portable fashion: + +- parameters use unnumbered `?` markers, +- date/time literals use the ODBC escape sequences `{d '...'}`, `{t '...'}`, + and `{ts '...'}`, which the driver translates for the database. + +Anything beyond that (e.g. vendor-specific functions used via +`sqlpp::verbatim`) is passed through unchanged, so it is your responsibility +to only use SQL that your database understands. + +## Creating a connection + +A connection configuration holds exactly one way of connecting — either a +data source configured in the driver manager (e.g. `odbc.ini`) or a complete +connection string: + +```c++ +auto config = std::make_shared(); + +// Either: connect to a configured data source (SQLConnect) +config->source = sqlpp::odbc::data_source{ + .name = "my_dsn", .username = "user", .password = "password"}; + +// Or: connect using a connection string (SQLDriverConnect) +config->source = sqlpp::odbc::connection_string{ + "Driver=PostgreSQL Unicode;Server=localhost;Database=test;Uid=user;Pwd=password;"}; + +// Create a connection +sqlpp::odbc::connection db; +db.connect_using(config); // This can throw an exception. +``` + +See also the [logging documentation](/docs/logging.md). + +## Fetching multiple rows at once + +Select results are read through an ODBC block cursor where possible: the +connector binds the result columns to fixed-size buffers and fetches +`connection_config::row_array_size` rows per driver round trip (default: 64). + +This requires that the size of each row is known up front. Text and blob +columns qualify if their declared maximum size fits into +`connection_config::max_bound_column_size` bytes per row (default: 4096, +text columns are budgeted with 4 bytes per character for UTF-8). If any +column exceeds the limit — or the driver cannot tell its size, as with +unbounded `TEXT` columns — the result transparently falls back to fetching +one row at a time and streaming each value with `SQLGetData`, which has no +size limit. + +```c++ +auto config = std::make_shared(); +config->row_array_size = 256; // fetch up to 256 rows per round trip +config->max_bound_column_size = 65536; // allow wider text/blob columns +``` + +Setting `row_array_size = 1` disables multi-row fetch. + +## Transactions + +ODBC has no explicit `BEGIN`: `start_transaction` turns off autocommit and +`commit_transaction`/`rollback_transaction` end the transaction via +`SQLEndTran` and turn autocommit back on. + +If you pass an isolation level to `start_transaction`, it is set through +`SQL_ATTR_TXN_ISOLATION` and remains in effect for subsequent transactions on +the same connection as well. + +## Time of day precision + +ODBC's `SQL_TIME_STRUCT` and time escape sequence have no sub-second +precision. Fractional seconds of `sqlpp::time` values are dropped when +serializing or binding parameters. Timestamps (`sqlpp::timestamp`) keep their +microsecond precision. + +## `last_insert_id` + +ODBC has no portable way to report the id of the last inserted row, so the +connector does not offer `last_insert_id`. Use a select with the appropriate +function of your database (e.g. `last_insert_rowid()`, `lastval()`, +`SCOPE_IDENTITY()`) if you need it. + +## Exceptions + +In exceptional situations the connector throws `sqlpp::odbc::exception` which +carries the message, SQLSTATE, and native error code of the diagnostic +records: + +```c++ +try { + db(select(sqlpp::verbatim("nonsense").as(sqlpp::alias::a))); +} catch (const sqlpp::odbc::exception& e) { + std::println("Exception: {}, SQLSTATE: {}, native error: {}", e.what(), + e.sql_state(), e.native_error_code()); +} +``` + +[**\< Connectors**](/docs/connectors.md) diff --git a/include/sqlpp23/odbc/cursor_result.h b/include/sqlpp23/odbc/cursor_result.h new file mode 100644 index 000000000..88283f916 --- /dev/null +++ b/include/sqlpp23/odbc/cursor_result.h @@ -0,0 +1,669 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace sqlpp::odbc { +// Reads the rows of an executed statement through an ODBC cursor. +// +// If all selected columns can be bound to fixed-size buffers (numeric, +// date/time, and text/blob columns whose declared maximum size fits into +// connection_config::max_bound_column_size), the columns are bound with +// SQLBindCol and connection_config::row_array_size rows are fetched per +// driver round trip (a "block cursor" in ODBC terms). +// +// Otherwise rows are fetched one at a time and each value is streamed with +// SQLGetData into a buffer that grows as needed. +class cursor_result_t { + public: + enum class column_type : unsigned char { + boolean, + int64, + uint64, + floating_point, + text, + blob, + date, + time_of_day, + timestamp, + }; + + private: + struct column_t { + column_type type; + size_t buffer_size{0}; // bytes per row (only used in block mode) + size_t value_size{0}; // bytes of the current value (streaming mode) + bool fetched{false}; // current row's value was streamed already + std::vector buffer; + std::vector indicators; + }; + + std::shared_ptr _statement; // SQLHSTMT + const connection_config* _config{nullptr}; + std::vector _columns; + // Registered with the driver (SQL_ATTR_ROWS_FETCHED_PTR), heap-allocated so + // that the address stays stable when the result is moved. + std::unique_ptr _fetched_row_count; + void* _result_row_address{nullptr}; + bool _fetch_prepared{false}; + bool _block_mode{false}; + size_t _rows_in_buffer{0}; + size_t _current_row{0}; + + public: + cursor_result_t() = default; + cursor_result_t(std::shared_ptr statement, + const connection_config* config) + : _statement{std::move(statement)}, _config{config} { + if constexpr (debug_enabled) { + _config->debug.log(log_category::result, + "ODBC: constructing cursor result, using handle at {}", + std::hash{}(_statement.get())); + } + } + + cursor_result_t(const cursor_result_t&) = delete; + cursor_result_t(cursor_result_t&& rhs) = default; + cursor_result_t& operator=(const cursor_result_t&) = delete; + cursor_result_t& operator=(cursor_result_t&& rhs) { + if (this != &rhs) { + release_cursor(); + _statement = std::move(rhs._statement); + _config = rhs._config; + _columns = std::move(rhs._columns); + _fetched_row_count = std::move(rhs._fetched_row_count); + _result_row_address = rhs._result_row_address; + _fetch_prepared = rhs._fetch_prepared; + _block_mode = rhs._block_mode; + _rows_in_buffer = rhs._rows_in_buffer; + _current_row = rhs._current_row; + } + return *this; + } + + ~cursor_result_t() { release_cursor(); } + + bool operator==(const cursor_result_t& rhs) const { + return _statement == rhs._statement; + } + + template + void next(ResultRow& result_row) { + if (not _statement) { + sqlpp::detail::result_row_bridge{}.invalidate(result_row); + return; + } + + if (&result_row != _result_row_address) { + // Records the column plan via the bind_field functions below. + sqlpp::detail::result_row_bridge{}.bind_fields(result_row, *this); + if (not _fetch_prepared) { + prepare_fetch(); + _fetch_prepared = true; + } + _result_row_address = &result_row; + } + + if (next_row()) { + if (not result_row) { + sqlpp::detail::result_row_bridge{}.validate(result_row); + } + sqlpp::detail::result_row_bridge{}.read_fields(result_row, *this); + } else { + if (result_row) { + sqlpp::detail::result_row_bridge{}.invalidate(result_row); + } + } + } + + const debug_logger& debug() const { return _config->debug; } + + void plan_column(size_t field_index, column_type type) { + if (_columns.size() <= field_index) { + _columns.resize(field_index + 1); + } + auto& column = _columns[field_index]; + column.type = type; + column.buffer_size = fixed_buffer_size(type); // 0 for text and blob + } + + bool get_is_null(size_t field_index) { + auto& column = _columns[field_index]; + if (_block_mode) { + return column.indicators[_current_row] == SQL_NULL_DATA; + } + stream_value_if_required(field_index); + return column.indicators[0] == SQL_NULL_DATA; + } + + bool get_bool(size_t field_index) { + return get_fixed(field_index) != 0; + } + + int64_t get_int64(size_t field_index) { + return get_fixed(field_index); + } + + uint64_t get_uint64(size_t field_index) { + return get_fixed(field_index); + } + + double get_double(size_t field_index) { + return get_fixed(field_index); + } + + SQL_DATE_STRUCT get_date(size_t field_index) { + return get_fixed(field_index); + } + + SQL_TIME_STRUCT get_time(size_t field_index) { + return get_fixed(field_index); + } + + SQL_TIMESTAMP_STRUCT get_timestamp(size_t field_index) { + return get_fixed(field_index); + } + + std::string_view get_text(size_t field_index) { + const auto value = get_variable(field_index); + return {value.data(), value.size()}; + } + + std::span get_blob(size_t field_index) { + const auto value = get_variable(field_index); + return {reinterpret_cast(value.data()), value.size()}; + } + + private: + SQLHSTMT native_handle() const { return _statement.get(); } + + static size_t fixed_buffer_size(column_type type) { + switch (type) { + case column_type::boolean: + return 1; + case column_type::int64: + case column_type::uint64: + case column_type::floating_point: + return 8; + case column_type::date: + return sizeof(SQL_DATE_STRUCT); + case column_type::time_of_day: + return sizeof(SQL_TIME_STRUCT); + case column_type::timestamp: + return sizeof(SQL_TIMESTAMP_STRUCT); + case column_type::text: + case column_type::blob: + return 0; + } + return 0; + } + + static SQLSMALLINT c_type(column_type type) { + switch (type) { + case column_type::boolean: + return SQL_C_BIT; + case column_type::int64: + return SQL_C_SBIGINT; + case column_type::uint64: + return SQL_C_UBIGINT; + case column_type::floating_point: + return SQL_C_DOUBLE; + case column_type::date: + return SQL_C_TYPE_DATE; + case column_type::time_of_day: + return SQL_C_TYPE_TIME; + case column_type::timestamp: + return SQL_C_TYPE_TIMESTAMP; + case column_type::text: + return SQL_C_CHAR; + case column_type::blob: + return SQL_C_BINARY; + } + return SQL_C_DEFAULT; + } + + // Decides between block fetch and row-by-row streaming and sets up the + // column buffers accordingly. + void prepare_fetch() { + bool all_columns_bindable = true; + for (size_t index = 0; index < _columns.size(); ++index) { + auto& column = _columns[index]; + if (column.buffer_size != 0) { + continue; // fixed-size column + } + SQLULEN declared_size{0}; + SQLSMALLINT sql_type{}, decimal_digits{}, nullable{}, name_length{}; + SQLCHAR name[256]; + detail::throw_on_error( + SQLDescribeCol(native_handle(), static_cast(index + 1), + name, sizeof(name), &name_length, &sql_type, + &declared_size, &decimal_digits, &nullable), + "ODBC: could not describe result column", SQL_HANDLE_STMT, + native_handle()); + // Text sizes are reported in characters which can take up to 4 bytes + // each (UTF-8), plus the terminating NUL. + const size_t required_bytes = column.type == column_type::text + ? declared_size * 4 + 1 + : declared_size; + if (declared_size == 0 or + required_bytes > _config->max_bound_column_size) { + all_columns_bindable = false; + } else { + column.buffer_size = required_bytes; + } + } + + _block_mode = all_columns_bindable; + if (not _block_mode) { + if constexpr (debug_enabled) { + _config->debug.log(log_category::result, + "ODBC: streaming rows one at a time (result " + "contains long variable-size columns)"); + } + for (auto& column : _columns) { + column.indicators.resize(1); + // Text/blob buffers grow on demand while streaming, starting with + // the declared size if it is known. + column.buffer.resize(column.buffer_size); + } + return; + } + + size_t rowset_size = + _config->row_array_size > 0 ? _config->row_array_size : 1; + if (rowset_size > 1) { + const auto rc = SQLSetStmtAttr( + native_handle(), SQL_ATTR_ROW_ARRAY_SIZE, + reinterpret_cast(static_cast(rowset_size)), 0); + if (rc != SQL_SUCCESS and rc != SQL_SUCCESS_WITH_INFO) { + // The driver does not support block cursors. + rowset_size = 1; + } else { + // The driver may have lowered the value (SQLSTATE 01S02). + SQLULEN actual_size{1}; + if (SQLGetStmtAttr(native_handle(), SQL_ATTR_ROW_ARRAY_SIZE, + &actual_size, SQL_IS_UINTEGER, + nullptr) == SQL_SUCCESS) { + rowset_size = actual_size; + } + } + } + if constexpr (debug_enabled) { + _config->debug.log(log_category::result, + "ODBC: fetching rows in blocks of {}", rowset_size); + } + + _fetched_row_count = std::make_unique(0); + detail::throw_on_error( + SQLSetStmtAttr(native_handle(), SQL_ATTR_ROWS_FETCHED_PTR, + _fetched_row_count.get(), 0), + "ODBC: could not register the fetched-row counter", SQL_HANDLE_STMT, + native_handle()); + + for (size_t index = 0; index < _columns.size(); ++index) { + auto& column = _columns[index]; + column.buffer.resize(column.buffer_size * rowset_size); + column.indicators.resize(rowset_size); + detail::throw_on_error( + SQLBindCol(native_handle(), static_cast(index + 1), + c_type(column.type), column.buffer.data(), + static_cast(column.buffer_size), + column.indicators.data()), + "ODBC: could not bind result column", SQL_HANDLE_STMT, + native_handle()); + } + } + + bool next_row() { + if (_current_row + 1 < _rows_in_buffer) { + ++_current_row; + return true; + } + + if constexpr (debug_enabled) { + _config->debug.log(log_category::result, + "ODBC: fetching next {} from the driver", + _block_mode ? "rowset" : "row"); + } + const auto rc = SQLFetch(native_handle()); + if (rc == SQL_NO_DATA) { + _rows_in_buffer = 0; + _current_row = 0; + return false; + } + detail::throw_on_error(rc, "ODBC: could not fetch rows", SQL_HANDLE_STMT, + native_handle()); + _rows_in_buffer = _block_mode ? *_fetched_row_count : 1; + _current_row = 0; + if (not _block_mode) { + for (auto& column : _columns) { + column.fetched = false; + } + } + return _rows_in_buffer > 0; + } + + template + T get_fixed(size_t field_index) { + auto& column = _columns[field_index]; + T value{}; + if (_block_mode) { + std::memcpy(&value, + column.buffer.data() + _current_row * column.buffer_size, + sizeof(T)); + } else { + stream_value_if_required(field_index); + std::memcpy(&value, column.buffer.data(), sizeof(T)); + } + return value; + } + + std::string_view get_variable(size_t field_index) { + auto& column = _columns[field_index]; + if (_block_mode) { + const auto indicator = column.indicators[_current_row]; + if (indicator == SQL_NULL_DATA) { + return {}; + } + // Text buffers reserve one byte for the terminating NUL. + const size_t max_value_size = column.type == column_type::text + ? column.buffer_size - 1 + : column.buffer_size; + if (indicator == SQL_NO_TOTAL or + static_cast(indicator) > max_value_size) { + throw exception{ + "ODBC: result value did not fit into the bound column buffer; " + "increase connection_config::max_bound_column_size or fix the " + "column's declared size", + "01004"}; + } + return {column.buffer.data() + _current_row * column.buffer_size, + static_cast(indicator)}; + } + stream_value_if_required(field_index); + return {column.buffer.data(), column.value_size}; + } + + // Streaming mode: retrieves the value of the given column of the current + // row with SQLGetData. Columns are requested in increasing index order + // (fields are read in select order), which every ODBC driver supports. + void stream_value_if_required(size_t field_index) { + auto& column = _columns[field_index]; + if (column.fetched) { + return; + } + column.fetched = true; + + if (column.type != column_type::text and + column.type != column_type::blob) { // fixed-size column + const auto rc = SQLGetData( + native_handle(), static_cast(field_index + 1), + c_type(column.type), column.buffer.data(), + static_cast(column.buffer.size()), column.indicators.data()); + detail::throw_on_error(rc, "ODBC: could not get column data", + SQL_HANDLE_STMT, native_handle()); + return; + } + + // Variable-size column: read in parts, growing the buffer as needed. + const auto column_c_type = c_type(column.type); + // SQLGetData NUL-terminates character data. + const size_t terminator = column.type == column_type::text ? 1 : 0; + if (column.buffer.size() < 256) { + column.buffer.resize(256); + } + size_t received = 0; + for (;;) { + SQLLEN indicator{}; + const auto rc = SQLGetData( + native_handle(), static_cast(field_index + 1), + column_c_type, column.buffer.data() + received, + static_cast(column.buffer.size() - received), &indicator); + if (rc == SQL_NO_DATA) { + break; // the previous part already contained the complete value + } + detail::throw_on_error(rc, "ODBC: could not get column data", + SQL_HANDLE_STMT, native_handle()); + if (indicator == SQL_NULL_DATA) { + column.indicators[0] = SQL_NULL_DATA; + column.value_size = 0; + return; + } + if (rc == SQL_SUCCESS) { + // The final part; the indicator holds its size. + received += static_cast(indicator); + break; + } + // SQL_SUCCESS_WITH_INFO: the value was truncated to the buffer. The + // indicator holds the remaining size before this call (or SQL_NO_TOTAL + // if the driver does not know it). + const size_t part = column.buffer.size() - received - terminator; + received += part; + if (indicator == SQL_NO_TOTAL) { + column.buffer.resize(column.buffer.size() * 2); + } else { + column.buffer.resize( + received + (static_cast(indicator) - part) + terminator); + } + } + column.indicators[0] = static_cast(received); + column.value_size = received; + } + + // Leaves the statement ready for re-execution: closes the cursor and + // removes all references to buffers owned by this result. + void release_cursor() noexcept { + if (not _statement) { + return; + } + SQLFreeStmt(native_handle(), SQL_CLOSE); + SQLFreeStmt(native_handle(), SQL_UNBIND); + SQLSetStmtAttr(native_handle(), SQL_ATTR_ROWS_FETCHED_PTR, nullptr, 0); + SQLSetStmtAttr(native_handle(), SQL_ATTR_ROW_ARRAY_SIZE, + reinterpret_cast(SQLULEN{1}), 0); + } +}; + +inline void bind_field(cursor_result_t& result, + size_t field_index, + bool& /*value*/) { + result.plan_column(field_index, cursor_result_t::column_type::boolean); +} + +inline void bind_field(cursor_result_t& result, + size_t field_index, + int64_t& /*value*/) { + result.plan_column(field_index, cursor_result_t::column_type::int64); +} + +inline void bind_field(cursor_result_t& result, + size_t field_index, + uint64_t& /*value*/) { + result.plan_column(field_index, cursor_result_t::column_type::uint64); +} + +inline void bind_field(cursor_result_t& result, + size_t field_index, + double& /*value*/) { + result.plan_column(field_index, cursor_result_t::column_type::floating_point); +} + +inline void bind_field(cursor_result_t& result, + size_t field_index, + std::string_view& /*value*/) { + result.plan_column(field_index, cursor_result_t::column_type::text); +} + +inline void bind_field(cursor_result_t& result, + size_t field_index, + std::span& /*value*/) { + result.plan_column(field_index, cursor_result_t::column_type::blob); +} + +inline void bind_field(cursor_result_t& result, + size_t field_index, + std::chrono::sys_days& /*value*/) { + result.plan_column(field_index, cursor_result_t::column_type::date); +} + +inline void bind_field(cursor_result_t& result, + size_t field_index, + std::chrono::microseconds& /*value*/) { + result.plan_column(field_index, cursor_result_t::column_type::time_of_day); +} + +inline void bind_field(cursor_result_t& result, + size_t field_index, + ::sqlpp::chrono::sys_microseconds& /*value*/) { + result.plan_column(field_index, cursor_result_t::column_type::timestamp); +} + +inline void read_field(cursor_result_t& result, + size_t field_index, + bool& value) { + if constexpr (debug_enabled) { + result.debug().log(log_category::result, + "ODBC: reading boolean result at index {}", field_index); + } + value = result.get_bool(field_index); +} + +inline void read_field(cursor_result_t& result, + size_t field_index, + int64_t& value) { + if constexpr (debug_enabled) { + result.debug().log(log_category::result, + "ODBC: reading integral result at index {}", + field_index); + } + value = result.get_int64(field_index); +} + +inline void read_field(cursor_result_t& result, + size_t field_index, + uint64_t& value) { + if constexpr (debug_enabled) { + result.debug().log(log_category::result, + "ODBC: reading unsigned integral result at index {}", + field_index); + } + value = result.get_uint64(field_index); +} + +inline void read_field(cursor_result_t& result, + size_t field_index, + double& value) { + if constexpr (debug_enabled) { + result.debug().log(log_category::result, + "ODBC: reading floating_point result at index {}", + field_index); + } + value = result.get_double(field_index); +} + +inline void read_field(cursor_result_t& result, + size_t field_index, + std::string_view& value) { + if constexpr (debug_enabled) { + result.debug().log(log_category::result, + "ODBC: reading text result at index {}", field_index); + } + value = result.get_text(field_index); +} + +inline void read_field(cursor_result_t& result, + size_t field_index, + std::span& value) { + if constexpr (debug_enabled) { + result.debug().log(log_category::result, + "ODBC: reading blob result at index {}", field_index); + } + value = result.get_blob(field_index); +} + +inline void read_field(cursor_result_t& result, + size_t field_index, + std::chrono::sys_days& value) { + if constexpr (debug_enabled) { + result.debug().log(log_category::result, + "ODBC: reading date result at index {}", field_index); + } + const auto date = result.get_date(field_index); + value = std::chrono::year{date.year} / std::chrono::month{date.month} / + std::chrono::day{date.day}; +} + +inline void read_field(cursor_result_t& result, + size_t field_index, + std::chrono::microseconds& value) { + if constexpr (debug_enabled) { + result.debug().log(log_category::result, + "ODBC: reading time of day result at index {}", + field_index); + } + const auto time = result.get_time(field_index); + value = std::chrono::hours{time.hour} + std::chrono::minutes{time.minute} + + std::chrono::seconds{time.second}; +} + +inline void read_field(cursor_result_t& result, + size_t field_index, + ::sqlpp::chrono::sys_microseconds& value) { + if constexpr (debug_enabled) { + result.debug().log(log_category::result, + "ODBC: reading timestamp result at index {}", + field_index); + } + const auto ts = result.get_timestamp(field_index); + value = std::chrono::sys_days{std::chrono::year{ts.year} / + std::chrono::month{ts.month} / + std::chrono::day{ts.day}} + + std::chrono::hours{ts.hour} + std::chrono::minutes{ts.minute} + + std::chrono::seconds{ts.second} + + // The fraction is in billionths of a second. + std::chrono::duration_cast( + std::chrono::nanoseconds{ts.fraction}); +} + +} // namespace sqlpp::odbc diff --git a/include/sqlpp23/odbc/database/connection.h b/include/sqlpp23/odbc/database/connection.h new file mode 100644 index 000000000..7def2fe11 --- /dev/null +++ b/include/sqlpp23/odbc/database/connection.h @@ -0,0 +1,422 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sqlpp::odbc { + +struct command_result { + uint64_t affected_rows; +}; + +// Base connection class +class connection_base : public sqlpp::connection { + public: + using _connection_base_t = connection_base; + using _config_t = connection_config; + using _config_ptr_t = std::shared_ptr; + using _handle_t = detail::connection_handle; + + using _prepared_statement_t = prepared_statement_t; + + private: + friend sqlpp::statement_handler_t; + + bool _transaction_active{false}; + + void validate_connection_handle() const { + if (not _handle.native_handle()) { + throw std::logic_error{"connection handle used, but not initialized"}; + } + } + + std::shared_ptr allocate_statement() { + validate_connection_handle(); + SQLHANDLE statement{SQL_NULL_HANDLE}; + const auto rc = + SQLAllocHandle(SQL_HANDLE_STMT, native_handle(), &statement); + if (rc != SQL_SUCCESS and rc != SQL_SUCCESS_WITH_INFO) { + throw detail::make_exception("ODBC: could not allocate statement handle", + SQL_HANDLE_DBC, native_handle()); + } + return {statement, detail::handle_deleter{SQL_HANDLE_STMT}}; + } + + static uint64_t affected_row_count(SQLHSTMT statement) { + SQLLEN row_count{0}; + detail::throw_on_error(SQLRowCount(statement, &row_count), + "ODBC: could not determine affected row count", + SQL_HANDLE_STMT, statement); + // Drivers report -1 when the count is not applicable. + return row_count > 0 ? static_cast(row_count) : 0; + } + + // direct execution + command_result execute_direct(std::string_view statement) { + if constexpr (debug_enabled) { + _handle.debug().log(log_category::statement, "ODBC: executing: '{}'", + statement); + } + auto handle = allocate_statement(); + const auto rc = SQLExecDirect( + handle.get(), + reinterpret_cast(const_cast(statement.data())), + static_cast(statement.size())); + if (rc == SQL_NO_DATA) { + // Valid outcome of e.g. a searched update affecting no rows. + return {.affected_rows = 0}; + } + detail::throw_on_error(rc, "ODBC: could not execute statement", + SQL_HANDLE_STMT, handle.get()); + return {.affected_rows = affected_row_count(handle.get())}; + } + + // prepared execution + void execute_prepared(prepared_statement_t& prepared) { + if constexpr (debug_enabled) { + _handle.debug().log(log_category::statement, + "ODBC: executing prepared statement"); + } + const auto rc = SQLExecute(prepared.native_handle()); + if (rc == SQL_NO_DATA) { + return; + } + detail::throw_on_error(rc, "ODBC: could not execute prepared statement", + SQL_HANDLE_STMT, prepared.native_handle()); + } + + template + prepared_statement_t prepare_statement(const Statement& statement) { + context_t context{this}; + const auto query = to_sql_string(context, statement); + return prepared_statement_t{allocate_statement(), query, context._count, + _handle.config.get()}; + } + + template + prepared_statement_t& bind_prepared_parameters(PreparedStatement& statement) { + auto& prepared = + sqlpp::statement_handler_t{}.get_prepared_statement(statement); + prepared._reset(); + sqlpp::statement_handler_t{}.bind_parameters(statement); + return prepared; + } + + template + command_result run_prepared_command(PreparedCommand& command) { + auto& prepared = bind_prepared_parameters(command); + execute_prepared(prepared); + return {.affected_rows = affected_row_count(prepared.native_handle())}; + } + + //! select returns a result (which can be iterated row by row) + template + cursor_result_t _select(const Select& s) { + context_t context{this}; + const auto query = to_sql_string(context, s); + if constexpr (debug_enabled) { + _handle.debug().log(log_category::statement, "ODBC: selecting: '{}'", + query); + } + auto statement = allocate_statement(); + const auto rc = SQLExecDirect( + statement.get(), + reinterpret_cast(const_cast(query.data())), + static_cast(query.size())); + if (rc != SQL_NO_DATA) { + detail::throw_on_error(rc, "ODBC: could not execute select", + SQL_HANDLE_STMT, statement.get()); + } + return {std::move(statement), _handle.config.get()}; + } + + template + prepared_statement_t _prepare_select(const Select& s) { + return prepare_statement(s); + } + + template + cursor_result_t _run_prepared_select(PreparedSelect& s) { + auto& prepared = bind_prepared_parameters(s); + execute_prepared(prepared); + return {prepared._statement, _handle.config.get()}; + } + + //! insert returns the number of affected rows + //! Note: ODBC has no portable way to report the last inserted id. Use a + //! select with a database-specific function if you need it. + template + command_result _insert(const Insert& i) { + context_t context{this}; + return execute_direct(to_sql_string(context, i)); + } + + template + prepared_statement_t _prepare_insert(const Insert& i) { + return prepare_statement(i); + } + + template + command_result _run_prepared_insert(PreparedInsert& i) { + return run_prepared_command(i); + } + + //! update returns the number of affected rows + template + command_result _update(const Update& u) { + context_t context{this}; + return execute_direct(to_sql_string(context, u)); + } + + template + prepared_statement_t _prepare_update(const Update& u) { + return prepare_statement(u); + } + + template + command_result _run_prepared_update(PreparedUpdate& u) { + return run_prepared_command(u); + } + + //! delete_from returns the number of deleted rows + template + command_result _delete_from(const Delete& r) { + context_t context{this}; + return execute_direct(to_sql_string(context, r)); + } + + template + prepared_statement_t _prepare_delete_from(const Delete& r) { + return prepare_statement(r); + } + + template + command_result _run_prepared_delete_from(PreparedDelete& r) { + return run_prepared_command(r); + } + + //! Execute a single arbitrary statement (e.g. create a table) + template + command_result _execute(const Execute& x) { + context_t context{this}; + return execute_direct(to_sql_string(context, x)); + } + + template + prepared_statement_t _prepare_execute(const Execute& x) { + return prepare_statement(x); + } + + template + command_result _run_prepared_execute(PreparedExecute& x) { + return run_prepared_command(x); + } + + void set_autocommit(bool enabled) { + detail::throw_on_error( + SQLSetConnectAttr( + native_handle(), SQL_ATTR_AUTOCOMMIT, + reinterpret_cast(static_cast( + enabled ? SQL_AUTOCOMMIT_ON : SQL_AUTOCOMMIT_OFF)), + SQL_IS_UINTEGER), + "ODBC: could not change autocommit mode", SQL_HANDLE_DBC, + native_handle()); + } + + void end_transaction(SQLSMALLINT completion_type) { + detail::throw_on_error( + SQLEndTran(SQL_HANDLE_DBC, native_handle(), completion_type), + completion_type == SQL_COMMIT ? "ODBC: could not commit transaction" + : "ODBC: could not roll back transaction", + SQL_HANDLE_DBC, native_handle()); + set_autocommit(true); + _transaction_active = false; + } + + public: + template + requires(sqlpp::is_statement_v) + auto operator()(const T& t) { + sqlpp::check_run_consistency(t).verify(); + sqlpp::check_compatibility(t).verify(); + return sqlpp::statement_handler_t{}.run(t, *this); + } + + template + requires(sqlpp::is_prepared_statement_v>) + auto operator()(T&& t) { + return sqlpp::statement_handler_t{}.run(std::forward(t), *this); + } + + command_result operator()(std::string_view t) { return execute_direct(t); } + + template + requires(sqlpp::is_statement_v) + auto prepare(const T& t) { + sqlpp::check_prepare_consistency(t).verify(); + sqlpp::check_compatibility(t).verify(); + return sqlpp::statement_handler_t{}.prepare(t, *this); + } + + //! set the transaction isolation level for this connection + void set_default_isolation_level(isolation_level level) { + SQLULEN mask{}; + switch (level) { + case isolation_level::read_uncommitted: + mask = SQL_TXN_READ_UNCOMMITTED; + break; + case isolation_level::read_committed: + mask = SQL_TXN_READ_COMMITTED; + break; + case isolation_level::repeatable_read: + mask = SQL_TXN_REPEATABLE_READ; + break; + case isolation_level::serializable: + mask = SQL_TXN_SERIALIZABLE; + break; + case isolation_level::undefined: + throw sqlpp::exception{"ODBC: invalid isolation level"}; + } + detail::throw_on_error( + SQLSetConnectAttr(native_handle(), SQL_ATTR_TXN_ISOLATION, + reinterpret_cast(mask), SQL_IS_UINTEGER), + "ODBC: could not set the transaction isolation level", SQL_HANDLE_DBC, + native_handle()); + } + + //! get the currently active transaction isolation level + sqlpp::isolation_level get_default_isolation_level() { + // The attribute is documented as a 32-bit mask, the wider buffer guards + // against drivers that write SQLULEN. + SQLULEN mask{0}; + detail::throw_on_error( + SQLGetConnectAttr(native_handle(), SQL_ATTR_TXN_ISOLATION, &mask, + SQL_IS_UINTEGER, nullptr), + "ODBC: could not get the transaction isolation level", SQL_HANDLE_DBC, + native_handle()); + switch (mask) { + case SQL_TXN_READ_UNCOMMITTED: + return isolation_level::read_uncommitted; + case SQL_TXN_READ_COMMITTED: + return isolation_level::read_committed; + case SQL_TXN_REPEATABLE_READ: + return isolation_level::repeatable_read; + case SQL_TXN_SERIALIZABLE: + return isolation_level::serializable; + default: + return isolation_level::undefined; + } + } + + //! start a transaction by turning off autocommit; ODBC has no explicit + //! BEGIN. If an isolation level is given, it is set on the connection and + //! stays in effect for subsequent transactions, too. + void start_transaction(isolation_level level = isolation_level::undefined) { + if (level != isolation_level::undefined) { + set_default_isolation_level(level); + } + set_autocommit(false); + _transaction_active = true; + } + + //! commit transaction + void commit_transaction() { end_transaction(SQL_COMMIT); } + + //! rollback transaction + void rollback_transaction() { + if constexpr (debug_enabled) { + _handle.debug().log(log_category::connection, + "ODBC: rolling back unfinished transaction"); + } + end_transaction(SQL_ROLLBACK); + } + + //! report a rollback failure (will be called by transactions in case of a + //! rollback failure in the destructor) + void report_rollback_failure(const std::string& message) noexcept { + if constexpr (debug_enabled) { + _handle.debug().log(log_category::connection, "rollback failure: {}", + message); + } + } + + //! check if transaction is active + bool is_transaction_active() { return _transaction_active; } + + SQLHDBC native_handle() const { return _handle.native_handle(); } + + // Standard SQL string escaping (doubling single quotes). This works + // without asking the driver, but be aware that some databases have + // non-standard quirks (e.g. backslash escapes in MySQL's default mode). + // Prefer prepared statement parameters over string literals. + std::string escape(const std::string_view& s) const { + auto result = std::string{}; + result.reserve(s.size() * 2); + for (const auto c : s) { + if (c == '\'') + result.push_back(c); // Escaping + result.push_back(c); + } + return result; + } + + protected: + _handle_t _handle; + + // Constructors + connection_base() = default; + connection_base(_handle_t handle) : _handle{std::move(handle)} {} +}; + +inline auto context_t::escape(std::string_view t) -> std::string { + return _db->escape(t); +} + +using connection = sqlpp::normal_connection; +using pooled_connection = sqlpp::pooled_connection; +} // namespace sqlpp::odbc diff --git a/include/sqlpp23/odbc/database/connection_config.h b/include/sqlpp23/odbc/database/connection_config.h new file mode 100644 index 000000000..b17764e78 --- /dev/null +++ b/include/sqlpp23/odbc/database/connection_config.h @@ -0,0 +1,79 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include + +#include + +namespace sqlpp::odbc { +// Connect to a data source that is configured in the ODBC driver manager +// (e.g. in odbc.ini), using SQLConnect. +struct data_source { + std::string name; + std::string username; + std::string password; + + bool operator==(const data_source&) const = default; +}; + +// Connect using a complete ODBC connection string, e.g. +// "Driver=PostgreSQL Unicode;Server=localhost;Database=test;Uid=me;Pwd=pw;" +// using SQLDriverConnect (without prompting). +struct connection_string { + std::string value; + + bool operator==(const connection_string&) const = default; +}; + +struct connection_config { + // There is exactly one way to connect per configuration: either through a + // configured data source or through a connection string. + std::variant source; + + // Number of rows fetched per driver round trip when a select result can be + // bound to fixed-size buffers (see max_bound_column_size). 1 disables + // multi-row fetch. + std::size_t row_array_size{64}; + + // Per-row buffer limit (in bytes) for binding text and blob result columns. + // Columns whose maximum size is unknown or would exceed this limit make the + // result fall back to fetching one row at a time (streaming each value via + // SQLGetData), which has no size limit. + std::size_t max_bound_column_size{4096}; + + debug_logger debug; // not compared + + bool operator==(const connection_config& other) const { + return source == other.source and row_array_size == other.row_array_size and + max_bound_column_size == other.max_bound_column_size; + } +}; +} // namespace sqlpp::odbc diff --git a/include/sqlpp23/odbc/database/connection_handle.h b/include/sqlpp23/odbc/database/connection_handle.h new file mode 100644 index 000000000..897540bf8 --- /dev/null +++ b/include/sqlpp23/odbc/database/connection_handle.h @@ -0,0 +1,174 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace sqlpp::odbc::detail { +struct handle_deleter { + SQLSMALLINT handle_type; + void operator()(SQLHANDLE handle) const noexcept { + SQLFreeHandle(handle_type, handle); + } +}; + +// SQLHANDLE is void*, so this holds any ODBC handle. +using unique_handle = + std::unique_ptr, handle_deleter>; + +inline unique_handle allocate_handle(SQLSMALLINT handle_type, + SQLHANDLE parent) { + SQLHANDLE handle{SQL_NULL_HANDLE}; + const auto rc = SQLAllocHandle(handle_type, parent, &handle); + if (rc != SQL_SUCCESS and rc != SQL_SUCCESS_WITH_INFO) { + if (parent != SQL_NULL_HANDLE) { + throw make_exception( + "ODBC: could not allocate handle", + handle_type == SQL_HANDLE_DBC ? SQL_HANDLE_ENV : SQL_HANDLE_DBC, + parent); + } + throw exception{"ODBC: could not allocate environment handle"}; + } + return unique_handle{handle, handle_deleter{handle_type}}; +} + +struct connection_handle { + std::shared_ptr config; + // Declaration order matters: the connection is freed before the environment. + unique_handle environment; + unique_handle connection; + + connection_handle() = default; + + connection_handle(const std::shared_ptr& conf) + : config{conf}, + environment{allocate_handle(SQL_HANDLE_ENV, SQL_NULL_HANDLE)} { + throw_on_error( + SQLSetEnvAttr(environment.get(), SQL_ATTR_ODBC_VERSION, + reinterpret_cast(SQL_OV_ODBC3_80), 0), + "ODBC: could not request ODBC 3.8", SQL_HANDLE_ENV, environment.get()); + connection = allocate_handle(SQL_HANDLE_DBC, environment.get()); + + std::visit([this](const auto& source) { connect(source); }, conf->source); + } + + connection_handle(const connection_handle&) = delete; + connection_handle(connection_handle&&) = default; + connection_handle& operator=(const connection_handle&) = delete; + connection_handle& operator=(connection_handle&& rhs) { + if (this != &rhs) { + disconnect(); + config = std::move(rhs.config); + connection = std::move(rhs.connection); + environment = std::move(rhs.environment); + } + return *this; + } + + ~connection_handle() { disconnect(); } + + SQLHDBC native_handle() const { return connection.get(); } + + bool is_connected() const { + if (not connection) { + return false; + } + // SQL_ATTR_CONNECTION_DEAD reflects the last known state of the + // connection without a server round trip. The attribute is documented as + // 32 bit, the wider buffer guards against drivers that write SQLULEN. + SQLULEN dead{SQL_CD_FALSE}; + const auto rc = + SQLGetConnectAttr(connection.get(), SQL_ATTR_CONNECTION_DEAD, &dead, + SQL_IS_UINTEGER, nullptr); + if (rc != SQL_SUCCESS and rc != SQL_SUCCESS_WITH_INFO) { + // The driver cannot tell, assume the connection is still alive. + return true; + } + return dead != SQL_CD_TRUE; + } + + bool ping_server() const { + // ODBC has no portable ping. Drivers that implement + // SQL_ATTR_CONNECTION_DEAD actively are covered by this, for others this + // is a passive check only. + return is_connected(); + } + + const debug_logger& debug() const { return config->debug; } + + private: + void connect(const data_source& source) { + if constexpr (debug_enabled) { + debug().log(log_category::connection, "ODBC: connecting to DSN '{}'", + source.name); + } + throw_on_error( + SQLConnect( + connection.get(), to_sqlchar_pointer(source.name), + static_cast(source.name.size()), + source.username.empty() ? nullptr + : to_sqlchar_pointer(source.username), + static_cast(source.username.size()), + source.password.empty() ? nullptr + : to_sqlchar_pointer(source.password), + static_cast(source.password.size())), + "ODBC: could not connect to data source '" + source.name + "'", + SQL_HANDLE_DBC, connection.get()); + } + + void connect(const connection_string& source) { + if constexpr (debug_enabled) { + debug().log(log_category::connection, + "ODBC: connecting using a connection string"); + } + throw_on_error( + SQLDriverConnect(connection.get(), nullptr, + to_sqlchar_pointer(source.value), + static_cast(source.value.size()), nullptr, + 0, nullptr, SQL_DRIVER_NOPROMPT), + "ODBC: could not connect using the connection string", SQL_HANDLE_DBC, + connection.get()); + } + + void disconnect() noexcept { + if (connection) { + SQLDisconnect(connection.get()); + } + } +}; +} // namespace sqlpp::odbc::detail diff --git a/include/sqlpp23/odbc/database/connection_pool.h b/include/sqlpp23/odbc/database/connection_pool.h new file mode 100644 index 000000000..ffb4ee5f0 --- /dev/null +++ b/include/sqlpp23/odbc/database/connection_pool.h @@ -0,0 +1,35 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include + +namespace sqlpp::odbc { +using connection_pool = sqlpp::connection_pool; +} // namespace sqlpp::odbc diff --git a/include/sqlpp23/odbc/database/exception.h b/include/sqlpp23/odbc/database/exception.h new file mode 100644 index 000000000..c6f4f255d --- /dev/null +++ b/include/sqlpp23/odbc/database/exception.h @@ -0,0 +1,56 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +#include + +#include + +namespace sqlpp::odbc { +class exception : public sqlpp::exception { + std::string _sql_state; + SQLINTEGER _native_error_code; + + public: + exception(const std::string& what_arg, + std::string sql_state = {}, + SQLINTEGER native_error_code = 0) + : sqlpp::exception{what_arg}, + _sql_state{std::move(sql_state)}, + _native_error_code{native_error_code} {} + + // Five-character SQLSTATE of the first diagnostic record, e.g. "42S02". + // Empty if no diagnostic record was available. + const std::string& sql_state() const { return _sql_state; } + + // Driver-specific error code of the first diagnostic record. + SQLINTEGER native_error_code() const { return _native_error_code; } +}; +} // namespace sqlpp::odbc diff --git a/include/sqlpp23/odbc/database/serializer_context.h b/include/sqlpp23/odbc/database/serializer_context.h new file mode 100644 index 000000000..cc2570540 --- /dev/null +++ b/include/sqlpp23/odbc/database/serializer_context.h @@ -0,0 +1,53 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include + +namespace sqlpp::odbc { + +class connection_base; + +// Context for serialization +struct context_t { + explicit context_t(connection_base* db) : _db(db) {} + context_t(const context_t&) = delete; + context_t(context_t&&) = delete; + context_t& operator=(const context_t&) = delete; + context_t& operator=(context_t&&) = delete; + + // The implementation is in connection.h + auto escape(std::string_view t) -> std::string; + + size_t _count = 0; + connection_base* _db; +}; + +} // namespace sqlpp::odbc diff --git a/include/sqlpp23/odbc/detail/diagnostics.h b/include/sqlpp23/odbc/detail/diagnostics.h new file mode 100644 index 000000000..42fd01408 --- /dev/null +++ b/include/sqlpp23/odbc/detail/diagnostics.h @@ -0,0 +1,93 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include + +#include +#include + +#include + +namespace sqlpp::odbc::detail { +// Builds an exception from all diagnostic records of the given handle. +// SQLSTATE and native error code are taken from the first record. +inline exception make_exception(std::string_view context, + SQLSMALLINT handle_type, + SQLHANDLE handle) { + std::string message{context}; + std::string sql_state; + SQLINTEGER native_error_code{0}; + + for (SQLSMALLINT record_number = 1;; ++record_number) { + SQLCHAR state[SQL_SQLSTATE_SIZE + 1] = {}; + SQLCHAR text[SQL_MAX_MESSAGE_LENGTH] = {}; + SQLINTEGER native_error{0}; + SQLSMALLINT text_length{0}; + const auto rc = + SQLGetDiagRec(handle_type, handle, record_number, state, &native_error, + text, sizeof(text), &text_length); + if (rc != SQL_SUCCESS and rc != SQL_SUCCESS_WITH_INFO) { + break; + } + if (record_number == 1) { + sql_state = reinterpret_cast(state); + native_error_code = native_error; + } + message += ": ["; + message += reinterpret_cast(state); + message += "] "; + message += reinterpret_cast(text); + } + + if (sql_state.empty()) { + message += ": no diagnostic information available"; + } + + return exception{message, std::move(sql_state), native_error_code}; +} + +// Throws if the return code indicates failure. SQL_SUCCESS_WITH_INFO is +// treated as success. Returns the return code so that callers can +// distinguish e.g. SQL_NO_DATA where it is a valid outcome. +inline SQLRETURN throw_on_error(SQLRETURN rc, + std::string_view context, + SQLSMALLINT handle_type, + SQLHANDLE handle) { + if (rc == SQL_SUCCESS or rc == SQL_SUCCESS_WITH_INFO or rc == SQL_NO_DATA) { + return rc; + } + throw make_exception(context, handle_type, handle); +} + +// ODBC functions take non-const SQLCHAR* for input strings. +inline SQLCHAR* to_sqlchar_pointer(const std::string& s) { + return reinterpret_cast(const_cast(s.data())); +} +} // namespace sqlpp::odbc::detail diff --git a/include/sqlpp23/odbc/odbc.h b/include/sqlpp23/odbc/odbc.h new file mode 100644 index 000000000..052830df9 --- /dev/null +++ b/include/sqlpp23/odbc/odbc.h @@ -0,0 +1,31 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include diff --git a/include/sqlpp23/odbc/prepared_statement.h b/include/sqlpp23/odbc/prepared_statement.h new file mode 100644 index 000000000..288a7983f --- /dev/null +++ b/include/sqlpp23/odbc/prepared_statement.h @@ -0,0 +1,352 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace sqlpp::odbc { +// Forward declaration +class connection_base; + +class prepared_statement_t { + friend class ::sqlpp::odbc::connection_base; + + // Owned storage for parameter values that have to be converted to ODBC + // structs and for the length/NULL indicators. The driver reads from these + // addresses when the statement is executed, so they have to remain stable: + // the vector is sized once in the constructor and never resized. + struct parameter_data { + union { + unsigned char bool_; + int64_t int64_; + uint64_t uint64_; + double double_; + SQL_DATE_STRUCT date_; + SQL_TIME_STRUCT time_; + SQL_TIMESTAMP_STRUCT timestamp_; + }; + SQLLEN length_or_indicator; + }; + + std::shared_ptr _statement; // SQLHSTMT + const connection_config* _config; + std::vector _parameters; + + public: + prepared_statement_t() = delete; + prepared_statement_t(std::shared_ptr statement, + std::string_view statement_text, + size_t parameter_count, + const connection_config* config) + : _statement{std::move(statement)}, + _config{config}, + _parameters(parameter_count) { + if constexpr (debug_enabled) { + _config->debug.log(log_category::statement, "ODBC: preparing: '{}'", + statement_text); + } + detail::throw_on_error( + SQLPrepare(native_handle(), + reinterpret_cast( + const_cast(statement_text.data())), + static_cast(statement_text.size())), + "ODBC: could not prepare statement", SQL_HANDLE_STMT, native_handle()); + } + + prepared_statement_t(const prepared_statement_t&) = delete; + prepared_statement_t(prepared_statement_t&& rhs) = default; + prepared_statement_t& operator=(const prepared_statement_t&) = delete; + prepared_statement_t& operator=(prepared_statement_t&&) = default; + ~prepared_statement_t() = default; + + bool operator==(const prepared_statement_t& rhs) const { + return _statement == rhs._statement; + } + + SQLHSTMT native_handle() const { return _statement.get(); } + const debug_logger& debug() const { return _config->debug; } + + void _reset() { + if constexpr (debug_enabled) { + _config->debug.log(log_category::statement, + "ODBC: closing cursor of prepared statement"); + } + // Closes the cursor of a previous execution, if any. SQL_CLOSE is + // explicitly harmless when no cursor is open. + SQLFreeStmt(native_handle(), SQL_CLOSE); + } + + void bind_parameter(size_t parameter_index, const bool& value) { + auto& data = _parameters[parameter_index]; + data.bool_ = value; + data.length_or_indicator = 0; + bind(parameter_index, SQL_C_BIT, SQL_BIT, 1, 0, &data.bool_, 0); + } + + void bind_parameter(size_t parameter_index, const int64_t& value) { + auto& data = _parameters[parameter_index]; + data.int64_ = value; + data.length_or_indicator = 0; + bind(parameter_index, SQL_C_SBIGINT, SQL_BIGINT, 0, 0, &data.int64_, 0); + } + + void bind_parameter(size_t parameter_index, const uint64_t& value) { + auto& data = _parameters[parameter_index]; + data.uint64_ = value; + data.length_or_indicator = 0; + bind(parameter_index, SQL_C_UBIGINT, SQL_BIGINT, 0, 0, &data.uint64_, 0); + } + + void bind_parameter(size_t parameter_index, const double& value) { + auto& data = _parameters[parameter_index]; + data.double_ = value; + data.length_or_indicator = 0; + bind(parameter_index, SQL_C_DOUBLE, SQL_DOUBLE, 0, 0, &data.double_, 0); + } + + // Note: The text is not copied. The caller (the prepared statement of the + // core library) owns it and keeps it alive until execution. + void bind_parameter(size_t parameter_index, const std::string_view& value) { + auto& data = _parameters[parameter_index]; + data.length_or_indicator = static_cast(value.size()); + bind(parameter_index, SQL_C_CHAR, + value.size() > 8000 ? SQL_LONGVARCHAR : SQL_VARCHAR, + value.empty() ? 1 : value.size(), 0, const_cast(value.data()), + static_cast(value.size())); + } + + // Note: The blob is not copied, see the note for text above. + void bind_parameter(size_t parameter_index, + const std::vector& value) { + auto& data = _parameters[parameter_index]; + data.length_or_indicator = static_cast(value.size()); + bind(parameter_index, SQL_C_BINARY, + value.size() > 8000 ? SQL_LONGVARBINARY : SQL_VARBINARY, + value.empty() ? 1 : value.size(), 0, + const_cast(value.data()), static_cast(value.size())); + } + + void bind_parameter(size_t parameter_index, + const std::chrono::sys_days& value) { + const std::chrono::year_month_day ymd{value}; + auto& data = _parameters[parameter_index]; + data.date_ = { + .year = static_cast(static_cast(ymd.year())), + .month = static_cast(static_cast(ymd.month())), + .day = static_cast(static_cast(ymd.day()))}; + data.length_or_indicator = 0; + bind(parameter_index, SQL_C_TYPE_DATE, SQL_TYPE_DATE, SQL_DATE_LEN, 0, + &data.date_, 0); + } + + // Note: ODBC's SQL_TIME_STRUCT has no sub-second precision, fractional + // seconds are dropped. + void bind_parameter(size_t parameter_index, + const std::chrono::microseconds& value) { + const auto hours = std::chrono::floor(value); + const auto minutes = + std::chrono::floor(value - hours); + const auto seconds = + std::chrono::floor(value - hours - minutes); + auto& data = _parameters[parameter_index]; + data.time_ = {.hour = static_cast(hours.count()), + .minute = static_cast(minutes.count()), + .second = static_cast(seconds.count())}; + data.length_or_indicator = 0; + bind(parameter_index, SQL_C_TYPE_TIME, SQL_TYPE_TIME, SQL_TIME_LEN, 0, + &data.time_, 0); + } + + void bind_parameter(size_t parameter_index, + const ::sqlpp::chrono::sys_microseconds& value) { + const auto days = std::chrono::floor(value); + const std::chrono::year_month_day ymd{days}; + const std::chrono::hh_mm_ss time{value - days}; + auto& data = _parameters[parameter_index]; + data.timestamp_ = { + .year = static_cast(static_cast(ymd.year())), + .month = static_cast(static_cast(ymd.month())), + .day = static_cast(static_cast(ymd.day())), + .hour = static_cast(time.hours().count()), + .minute = static_cast(time.minutes().count()), + .second = static_cast(time.seconds().count()), + // Billionths of a second. + .fraction = static_cast( + std::chrono::duration_cast( + time.subseconds()) + .count())}; + data.length_or_indicator = 0; + // Column size 26 and 6 decimal digits: "YYYY-MM-DD hh:mm:ss.ffffff" + bind(parameter_index, SQL_C_TYPE_TIMESTAMP, SQL_TYPE_TIMESTAMP, 26, 6, + &data.timestamp_, 0); + } + + void bind_null(size_t parameter_index) { + if constexpr (debug_enabled) { + _config->debug.log(log_category::parameter, + "ODBC: binding NULL parameter at index {}", + parameter_index); + } + auto& data = _parameters[parameter_index]; + data.length_or_indicator = SQL_NULL_DATA; + bind(parameter_index, SQL_C_DEFAULT, SQL_VARCHAR, 1, 0, nullptr, 0); + } + + private: + void bind(size_t parameter_index, + SQLSMALLINT c_type, + SQLSMALLINT sql_type, + SQLULEN column_size, + SQLSMALLINT decimal_digits, + void* value, + SQLLEN buffer_length) { + detail::throw_on_error( + SQLBindParameter(native_handle(), + static_cast(parameter_index + 1), + SQL_PARAM_INPUT, c_type, sql_type, column_size, + decimal_digits, value, buffer_length, + &_parameters[parameter_index].length_or_indicator), + "ODBC: could not bind parameter", SQL_HANDLE_STMT, native_handle()); + } +}; + +inline void bind_parameter(prepared_statement_t& statement, + size_t parameter_index, + const bool& value) { + if constexpr (debug_enabled) { + statement.debug().log(log_category::parameter, + "ODBC: binding boolean parameter {} at index {}", + value, parameter_index); + } + statement.bind_parameter(parameter_index, value); +} + +inline void bind_parameter(prepared_statement_t& statement, + size_t parameter_index, + const int64_t& value) { + if constexpr (debug_enabled) { + statement.debug().log(log_category::parameter, + "ODBC: binding integral parameter {} at index {}", + value, parameter_index); + } + statement.bind_parameter(parameter_index, value); +} + +inline void bind_parameter(prepared_statement_t& statement, + size_t parameter_index, + const uint64_t& value) { + if constexpr (debug_enabled) { + statement.debug().log( + log_category::parameter, + "ODBC: binding unsigned integral parameter {} at index {}", value, + parameter_index); + } + statement.bind_parameter(parameter_index, value); +} + +inline void bind_parameter(prepared_statement_t& statement, + size_t parameter_index, + const double& value) { + if constexpr (debug_enabled) { + statement.debug().log( + log_category::parameter, + "ODBC: binding floating_point parameter {} at index {}", value, + parameter_index); + } + statement.bind_parameter(parameter_index, value); +} + +inline void bind_parameter(prepared_statement_t& statement, + size_t parameter_index, + const std::string_view& value) { + if constexpr (debug_enabled) { + statement.debug().log(log_category::parameter, + "ODBC: binding text parameter '{}' at index {}", + value, parameter_index); + } + statement.bind_parameter(parameter_index, value); +} + +inline void bind_parameter(prepared_statement_t& statement, + size_t parameter_index, + const std::vector& value) { + if constexpr (debug_enabled) { + statement.debug().log( + log_category::parameter, + "ODBC: binding blob parameter with {} bytes at index {}", value.size(), + parameter_index); + } + statement.bind_parameter(parameter_index, value); +} + +inline void bind_parameter(prepared_statement_t& statement, + size_t parameter_index, + const std::chrono::sys_days& value) { + if constexpr (debug_enabled) { + statement.debug().log(log_category::parameter, + "ODBC: binding date parameter {} at index {}", value, + parameter_index); + } + statement.bind_parameter(parameter_index, value); +} + +inline void bind_parameter(prepared_statement_t& statement, + size_t parameter_index, + const std::chrono::microseconds& value) { + if constexpr (debug_enabled) { + statement.debug().log(log_category::parameter, + "ODBC: binding time of day parameter {} at index {}", + value, parameter_index); + } + statement.bind_parameter(parameter_index, value); +} + +inline void bind_parameter(prepared_statement_t& statement, + size_t parameter_index, + const ::sqlpp::chrono::sys_microseconds& value) { + if constexpr (debug_enabled) { + statement.debug().log(log_category::parameter, + "ODBC: binding timestamp parameter {} at index {}", + value, parameter_index); + } + statement.bind_parameter(parameter_index, value); +} + +} // namespace sqlpp::odbc diff --git a/include/sqlpp23/odbc/to_sql_string.h b/include/sqlpp23/odbc/to_sql_string.h new file mode 100644 index 000000000..69fecd559 --- /dev/null +++ b/include/sqlpp23/odbc/to_sql_string.h @@ -0,0 +1,71 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include + +#include +#include +#include +#include + +namespace sqlpp::odbc { +// ODBC uses unnumbered '?' parameter markers. +template +auto to_sql_string(context_t& context, const parameter_t&) + -> std::string { + ++context._count; + return "?"; +} + +// Date and time literals use the ODBC escape sequences which every driver +// translates into the syntax of its database. +inline auto to_sql_string(context_t&, const std::chrono::sys_days& t) + -> std::string { + return std::format("{{d '{0:%Y-%m-%d}'}}", t); +} + +// Note: The ODBC time escape sequence has no sub-second precision, +// fractional seconds are dropped. +inline auto to_sql_string(context_t&, const std::chrono::microseconds& t) + -> std::string { + return std::format("{{t '{0:%H:%M:%S}'}}", + std::chrono::floor(t)); +} + +template +auto to_sql_string( + context_t&, + const std::chrono::time_point& t) + -> std::string { + return std::format("{{ts '{0:%Y-%m-%d %H:%M:%S}'}}", t); +} + +} // namespace sqlpp::odbc diff --git a/modules/sqlpp23.odbc.cppm b/modules/sqlpp23.odbc.cppm new file mode 100644 index 000000000..77ca1d988 --- /dev/null +++ b/modules/sqlpp23.odbc.cppm @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +module; + +#include + +export module sqlpp23.odbc; + +export namespace sqlpp::odbc { +using ::sqlpp::odbc::bind_field; +using ::sqlpp::odbc::bind_parameter; +using ::sqlpp::odbc::read_field; + +using ::sqlpp::odbc::connection; +using ::sqlpp::odbc::connection_config; +using ::sqlpp::odbc::connection_pool; +using ::sqlpp::odbc::connection_string; +using ::sqlpp::odbc::context_t; +using ::sqlpp::odbc::data_source; +using ::sqlpp::odbc::pooled_connection; + +using ::sqlpp::odbc::command_result; +using ::sqlpp::odbc::cursor_result_t; +using ::sqlpp::odbc::exception; +using ::sqlpp::odbc::prepared_statement_t; + +using ::sqlpp::odbc::to_sql_string; +} // namespace sqlpp::odbc diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 958dcb48d..d1d6cd70c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,6 +32,10 @@ if(BUILD_MYSQL_CONNECTOR OR BUILD_MARIADB_CONNECTOR) add_subdirectory(mysql) endif() +if(BUILD_ODBC_CONNECTOR) + add_subdirectory(odbc) +endif() + if(BUILD_POSTGRESQL_CONNECTOR) add_subdirectory(postgresql) endif() diff --git a/tests/core/types/detail/get_last_if.cpp b/tests/core/types/detail/get_last_if.cpp index 027ca56dc..d5dd17536 100644 --- a/tests/core/types/detail/get_last_if.cpp +++ b/tests/core/types/detail/get_last_if.cpp @@ -54,12 +54,7 @@ void test_get_last_if() { float, int64_t, short, size_t>, short>::value, ""); - static_assert( - std::is_same< - sqlpp::detail::get_last_if_t, - size_t>::value, - ""); + // Ending on a non-matching type static_assert( diff --git a/tests/include/sqlpp23/tests/odbc/all.h b/tests/include/sqlpp23/tests/odbc/all.h new file mode 100644 index 000000000..34e2b6a22 --- /dev/null +++ b/tests/include/sqlpp23/tests/odbc/all.h @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +// Note: To work around a GCC bug, make sure that for any standard header that +// is both included and imported from a module, the #include directive comes +// before the import declaration. For details see +// https://gcc.gnu.org/bugzilla/show_bug.cgi?id=114795#c3 + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#ifdef BUILD_WITH_MODULES +import sqlpp23.core; +import sqlpp23.odbc; +import sqlpp23.test.odbc.tables; +#else +#include +#include +#include +#include +#endif diff --git a/tests/include/sqlpp23/tests/odbc/make_test_connection.h b/tests/include/sqlpp23/tests/odbc/make_test_connection.h new file mode 100644 index 000000000..e87950c1c --- /dev/null +++ b/tests/include/sqlpp23/tests/odbc/make_test_connection.h @@ -0,0 +1,76 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include +#include + +#ifdef BUILD_WITH_MODULES +import sqlpp23.core; +import sqlpp23.odbc; +#else +#include +#include +#endif + +namespace sqlpp::odbc { +inline debug_logger get_debug_logger( + const std::vector& categories = {log_category::all}) { + return debug_logger(categories, + [](sqlpp::log_category, const std::string& message) { + std::clog << message << '\n'; + }); +} + +// Get configuration for test connection. The connection string is taken from +// the environment (SQLPP_ODBC_CONNECTION_STRING) since ODBC tests require an +// externally configured driver and database. +inline std::shared_ptr make_test_config( + const std::vector& categories = {log_category::all}) { + auto config = std::make_shared(); + + if (const char* value = std::getenv("SQLPP_ODBC_CONNECTION_STRING")) { + config->source = sqlpp::odbc::connection_string{value}; + } + config->debug = get_debug_logger(categories); + + return config; +} + +inline ::sqlpp::odbc::connection make_test_connection( + const std::vector& categories = {log_category::all}) { + namespace sql = sqlpp::odbc; + + auto config = make_test_config(categories); + + sql::connection db; + db.connect_using(config); + return db; +} +} // namespace sqlpp::odbc diff --git a/tests/include/sqlpp23/tests/odbc/serialize_helpers.h b/tests/include/sqlpp23/tests/odbc/serialize_helpers.h new file mode 100644 index 000000000..5d53839f3 --- /dev/null +++ b/tests/include/sqlpp23/tests/odbc/serialize_helpers.h @@ -0,0 +1,49 @@ +#pragma once + +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +#include + +// Serialization can be tested without a live connection: escaping is +// implemented in the connector, not in the driver. +#define SQLPP_COMPARE(expr, expected_string) \ + { \ + static sqlpp::odbc::connection compare_db; \ + sqlpp::odbc::context_t compare_context{&compare_db}; \ + \ + using sqlpp::to_sql_string; \ + const auto result = to_sql_string(compare_context, expr); \ + \ + if (result != expected_string) { \ + std::cerr << __FILE__ << " " << __LINE__ << '\n' \ + << "Expected: -->|" << expected_string << "|<--\n" \ + << "Received: -->|" << result << "|<--\n"; \ + return -1; \ + } \ + } diff --git a/tests/include/sqlpp23/tests/odbc/tables.h b/tests/include/sqlpp23/tests/odbc/tables.h new file mode 100644 index 000000000..ca2b79c7b --- /dev/null +++ b/tests/include/sqlpp23/tests/odbc/tables.h @@ -0,0 +1,209 @@ +#pragma once + +// clang-format off +// generated by ./scripts/sqlpp23-ddl2cpp --path-to-ddl tests/include/sqlpp23/tests/odbc/tables.sql --path-to-header tests/include/sqlpp23/tests/odbc/tables.h --namespace test --assume-auto-id --generate-table-creation-helper + +#include + +#include +#include +#include +#include + +namespace test { + template + void createTabFoo(Db& db) { + db(R"+++(DROP TABLE IF EXISTS tab_foo)+++"); + db(R"+++(CREATE TABLE tab_foo +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text_nn_d varchar(255) NOT NULL DEFAULT '', + int_n bigint, + double_n double precision, + u_int_n bigint UNSIGNED, + bool_n bool, + blob_n blob +))+++"); + } + + struct TabFoo_ { + struct Id { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(id, id); + using data_type = ::sqlpp::integral; + using has_default = std::true_type; + }; + struct TextNnD { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(text_nn_d, textNnD); + using data_type = ::sqlpp::text; + using has_default = std::true_type; + }; + struct IntN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(int_n, intN); + using data_type = std::optional<::sqlpp::integral>; + using has_default = std::true_type; + }; + struct DoubleN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(double_n, doubleN); + using data_type = std::optional<::sqlpp::floating_point>; + using has_default = std::true_type; + }; + struct UIntN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(u_int_n, uIntN); + using data_type = std::optional<::sqlpp::unsigned_integral>; + using has_default = std::true_type; + }; + struct BoolN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(bool_n, boolN); + using data_type = std::optional<::sqlpp::boolean>; + using has_default = std::true_type; + }; + struct BlobN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(blob_n, blobN); + using data_type = std::optional<::sqlpp::blob>; + using has_default = std::true_type; + }; + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(tab_foo, tabFoo); + template + using _table_columns = sqlpp::table_columns; + using _required_insert_columns = sqlpp::detail::type_set<>; + }; + using TabFoo = ::sqlpp::table_t; + + template + void createTabBar(Db& db) { + db(R"+++(DROP TABLE IF EXISTS tab_bar)+++"); + db(R"+++(CREATE TABLE tab_bar +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text_n varchar(255) NULL, + bool_nn bool NOT NULL DEFAULT false, + int_n int +))+++"); + } + + struct TabBar_ { + struct Id { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(id, id); + using data_type = ::sqlpp::integral; + using has_default = std::true_type; + }; + struct TextN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(text_n, textN); + using data_type = std::optional<::sqlpp::text>; + using has_default = std::true_type; + }; + struct BoolNn { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(bool_nn, boolNn); + using data_type = ::sqlpp::boolean; + using has_default = std::true_type; + }; + struct IntN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(int_n, intN); + using data_type = std::optional<::sqlpp::integral>; + using has_default = std::true_type; + }; + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(tab_bar, tabBar); + template + using _table_columns = sqlpp::table_columns; + using _required_insert_columns = sqlpp::detail::type_set<>; + }; + using TabBar = ::sqlpp::table_t; + + template + void createTabDateTime(Db& db) { + db(R"+++(DROP TABLE IF EXISTS tab_date_time)+++"); + db(R"+++(CREATE TABLE tab_date_time ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date_n date, + timestamp_n datetime(3), + date_timestamp_n_d datetime DEFAULT CURRENT_TIMESTAMP, + time_n time(3) +))+++"); + } + + struct TabDateTime_ { + struct Id { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(id, id); + using data_type = ::sqlpp::integral; + using has_default = std::true_type; + }; + struct DateN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(date_n, dateN); + using data_type = std::optional<::sqlpp::date>; + using has_default = std::true_type; + }; + struct TimestampN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(timestamp_n, timestampN); + using data_type = std::optional<::sqlpp::timestamp>; + using has_default = std::true_type; + }; + struct DateTimestampND { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(date_timestamp_n_d, dateTimestampND); + using data_type = std::optional<::sqlpp::timestamp>; + using has_default = std::true_type; + }; + struct TimeN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(time_n, timeN); + using data_type = std::optional<::sqlpp::time>; + using has_default = std::true_type; + }; + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(tab_date_time, tabDateTime); + template + using _table_columns = sqlpp::table_columns; + using _required_insert_columns = sqlpp::detail::type_set<>; + }; + using TabDateTime = ::sqlpp::table_t; + + template + void createTabDepartment(Db& db) { + db(R"+++(DROP TABLE IF EXISTS tab_department)+++"); + db(R"+++(CREATE TABLE tab_department ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name CHAR(100), + division VARCHAR(255) NOT NULL DEFAULT 'engineering' +))+++"); + } + + struct TabDepartment_ { + struct Id { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(id, id); + using data_type = ::sqlpp::integral; + using has_default = std::true_type; + }; + struct Name { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(name, name); + using data_type = std::optional<::sqlpp::text>; + using has_default = std::true_type; + }; + struct Division { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(division, division); + using data_type = ::sqlpp::text; + using has_default = std::true_type; + }; + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(tab_department, tabDepartment); + template + using _table_columns = sqlpp::table_columns; + using _required_insert_columns = sqlpp::detail::type_set<>; + }; + using TabDepartment = ::sqlpp::table_t; + +} // namespace test diff --git a/tests/include/sqlpp23/tests/odbc/tables.sql b/tests/include/sqlpp23/tests/odbc/tables.sql new file mode 100644 index 000000000..d74335f53 --- /dev/null +++ b/tests/include/sqlpp23/tests/odbc/tables.sql @@ -0,0 +1,40 @@ +DROP TABLE IF EXISTS tab_foo; + +CREATE TABLE tab_foo +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text_nn_d varchar(255) NOT NULL DEFAULT '', + int_n bigint, + double_n double precision, + u_int_n bigint UNSIGNED, + bool_n bool, + blob_n blob +); + +DROP TABLE IF EXISTS tab_bar; + +CREATE TABLE tab_bar +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text_n varchar(255) NULL, + bool_nn bool NOT NULL DEFAULT false, + int_n int +); + +DROP TABLE IF EXISTS tab_date_time; + +CREATE TABLE tab_date_time ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date_n date, + timestamp_n datetime(3), + date_timestamp_n_d datetime DEFAULT CURRENT_TIMESTAMP, + time_n time(3) +); + +DROP TABLE IF EXISTS tab_department; + +CREATE TABLE tab_department ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name CHAR(100), + division VARCHAR(255) NOT NULL DEFAULT 'engineering' +); diff --git a/tests/odbc/CMakeLists.txt b/tests/odbc/CMakeLists.txt new file mode 100644 index 000000000..e3b623743 --- /dev/null +++ b/tests/odbc/CMakeLists.txt @@ -0,0 +1,28 @@ +# Copyright (c) 2026, Leander Schulten +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +add_testing_target(NAME odbc MOD_DEPS sqlpp23::odbc_module) + +add_subdirectory(serialize) +add_subdirectory(usage) diff --git a/tests/odbc/modules/sqlpp23.test.odbc.tables.cppm b/tests/odbc/modules/sqlpp23.test.odbc.tables.cppm new file mode 100644 index 000000000..6fecdaaac --- /dev/null +++ b/tests/odbc/modules/sqlpp23.test.odbc.tables.cppm @@ -0,0 +1,210 @@ +module; + +// clang-format off +// generated by ./scripts/sqlpp23-ddl2cpp --path-to-ddl tests/include/sqlpp23/tests/odbc/tables.sql --path-to-module tests/odbc/modules/sqlpp23.test.odbc.tables.cppm --namespace test --assume-auto-id --module-name sqlpp23.test.odbc.tables --generate-table-creation-helper + +#include +#include + +export module sqlpp23.test.odbc.tables; + +import sqlpp23.core; + +namespace test { + export template + void createTabFoo(Db& db) { + db(R"+++(DROP TABLE IF EXISTS tab_foo)+++"); + db(R"+++(CREATE TABLE tab_foo +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text_nn_d varchar(255) NOT NULL DEFAULT '', + int_n bigint, + double_n double precision, + u_int_n bigint UNSIGNED, + bool_n bool, + blob_n blob +))+++"); + } + + export struct TabFoo_ { + struct Id { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(id, id); + using data_type = ::sqlpp::integral; + using has_default = std::true_type; + }; + struct TextNnD { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(text_nn_d, textNnD); + using data_type = ::sqlpp::text; + using has_default = std::true_type; + }; + struct IntN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(int_n, intN); + using data_type = std::optional<::sqlpp::integral>; + using has_default = std::true_type; + }; + struct DoubleN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(double_n, doubleN); + using data_type = std::optional<::sqlpp::floating_point>; + using has_default = std::true_type; + }; + struct UIntN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(u_int_n, uIntN); + using data_type = std::optional<::sqlpp::unsigned_integral>; + using has_default = std::true_type; + }; + struct BoolN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(bool_n, boolN); + using data_type = std::optional<::sqlpp::boolean>; + using has_default = std::true_type; + }; + struct BlobN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(blob_n, blobN); + using data_type = std::optional<::sqlpp::blob>; + using has_default = std::true_type; + }; + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(tab_foo, tabFoo); + template + using _table_columns = sqlpp::table_columns; + using _required_insert_columns = sqlpp::detail::type_set<>; + }; + export using TabFoo = ::sqlpp::table_t; + + export template + void createTabBar(Db& db) { + db(R"+++(DROP TABLE IF EXISTS tab_bar)+++"); + db(R"+++(CREATE TABLE tab_bar +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text_n varchar(255) NULL, + bool_nn bool NOT NULL DEFAULT false, + int_n int +))+++"); + } + + export struct TabBar_ { + struct Id { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(id, id); + using data_type = ::sqlpp::integral; + using has_default = std::true_type; + }; + struct TextN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(text_n, textN); + using data_type = std::optional<::sqlpp::text>; + using has_default = std::true_type; + }; + struct BoolNn { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(bool_nn, boolNn); + using data_type = ::sqlpp::boolean; + using has_default = std::true_type; + }; + struct IntN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(int_n, intN); + using data_type = std::optional<::sqlpp::integral>; + using has_default = std::true_type; + }; + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(tab_bar, tabBar); + template + using _table_columns = sqlpp::table_columns; + using _required_insert_columns = sqlpp::detail::type_set<>; + }; + export using TabBar = ::sqlpp::table_t; + + export template + void createTabDateTime(Db& db) { + db(R"+++(DROP TABLE IF EXISTS tab_date_time)+++"); + db(R"+++(CREATE TABLE tab_date_time ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date_n date, + timestamp_n datetime(3), + date_timestamp_n_d datetime DEFAULT CURRENT_TIMESTAMP, + time_n time(3) +))+++"); + } + + export struct TabDateTime_ { + struct Id { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(id, id); + using data_type = ::sqlpp::integral; + using has_default = std::true_type; + }; + struct DateN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(date_n, dateN); + using data_type = std::optional<::sqlpp::date>; + using has_default = std::true_type; + }; + struct TimestampN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(timestamp_n, timestampN); + using data_type = std::optional<::sqlpp::timestamp>; + using has_default = std::true_type; + }; + struct DateTimestampND { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(date_timestamp_n_d, dateTimestampND); + using data_type = std::optional<::sqlpp::timestamp>; + using has_default = std::true_type; + }; + struct TimeN { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(time_n, timeN); + using data_type = std::optional<::sqlpp::time>; + using has_default = std::true_type; + }; + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(tab_date_time, tabDateTime); + template + using _table_columns = sqlpp::table_columns; + using _required_insert_columns = sqlpp::detail::type_set<>; + }; + export using TabDateTime = ::sqlpp::table_t; + + export template + void createTabDepartment(Db& db) { + db(R"+++(DROP TABLE IF EXISTS tab_department)+++"); + db(R"+++(CREATE TABLE tab_department ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name CHAR(100), + division VARCHAR(255) NOT NULL DEFAULT 'engineering' +))+++"); + } + + export struct TabDepartment_ { + struct Id { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(id, id); + using data_type = ::sqlpp::integral; + using has_default = std::true_type; + }; + struct Name { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(name, name); + using data_type = std::optional<::sqlpp::text>; + using has_default = std::true_type; + }; + struct Division { + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(division, division); + using data_type = ::sqlpp::text; + using has_default = std::true_type; + }; + SQLPP_CREATE_NAME_TAG_FOR_SQL_AND_CPP(tab_department, tabDepartment); + template + using _table_columns = sqlpp::table_columns; + using _required_insert_columns = sqlpp::detail::type_set<>; + }; + export using TabDepartment = ::sqlpp::table_t; + + +} // namespace test diff --git a/tests/odbc/serialize/CMakeLists.txt b/tests/odbc/serialize/CMakeLists.txt new file mode 100644 index 000000000..95be64734 --- /dev/null +++ b/tests/odbc/serialize/CMakeLists.txt @@ -0,0 +1,26 @@ +# Copyright (c) 2026, Leander Schulten +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +add_subdirectory(data_types) +add_subdirectory(statement) diff --git a/tests/odbc/serialize/data_types/CMakeLists.txt b/tests/odbc/serialize/data_types/CMakeLists.txt new file mode 100644 index 000000000..32781d190 --- /dev/null +++ b/tests/odbc/serialize/data_types/CMakeLists.txt @@ -0,0 +1,30 @@ +# Copyright (c) 2026, Leander Schulten +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +create_tests_group( + date + text + time + timestamp +) diff --git a/tests/odbc/serialize/data_types/date.cpp b/tests/odbc/serialize/data_types/date.cpp new file mode 100644 index 000000000..1c3f3b291 --- /dev/null +++ b/tests/odbc/serialize/data_types/date.cpp @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +int main() { + std::chrono::sys_days tp = + static_cast(std::chrono::February / 8 / 2025); + + // Dates are serialized as ODBC date escape sequences. + SQLPP_COMPARE(tp, "{d '2025-02-08'}"); + + return 0; +} diff --git a/tests/odbc/serialize/data_types/text.cpp b/tests/odbc/serialize/data_types/text.cpp new file mode 100644 index 000000000..45c7661a1 --- /dev/null +++ b/tests/odbc/serialize/data_types/text.cpp @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +int main() { + // Single quotes are escaped by doubling them (standard SQL). + SQLPP_COMPARE(R"(a)", R"('a')"); + SQLPP_COMPARE(R"(')", R"('''')"); + SQLPP_COMPARE(R"(it's)", R"('it''s')"); + SQLPP_COMPARE(R"(\)", R"('\')"); + + return 0; +} diff --git a/tests/odbc/serialize/data_types/time.cpp b/tests/odbc/serialize/data_types/time.cpp new file mode 100644 index 000000000..af60cff61 --- /dev/null +++ b/tests/odbc/serialize/data_types/time.cpp @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +int main() { + using namespace std::chrono_literals; + + // Times of day are serialized as ODBC time escape sequences. + SQLPP_COMPARE(10h + 9min + 8s, "{t '10:09:08'}"); + + // The ODBC time escape sequence has no sub-second precision, fractional + // seconds are dropped. + SQLPP_COMPARE(10h + 9min + 8s + 123456us, "{t '10:09:08'}"); + + return 0; +} diff --git a/tests/odbc/serialize/data_types/timestamp.cpp b/tests/odbc/serialize/data_types/timestamp.cpp new file mode 100644 index 000000000..cbe29ce0b --- /dev/null +++ b/tests/odbc/serialize/data_types/timestamp.cpp @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +int main() { + using namespace std::chrono_literals; + + const auto tp = + static_cast(std::chrono::February / 8 / 2025) + + 10h + 9min + 8s + 123456us; + + // Timestamps are serialized as ODBC timestamp escape sequences. + SQLPP_COMPARE(tp, "{ts '2025-02-08 10:09:08.123456'}"); + + return 0; +} diff --git a/tests/odbc/serialize/statement/CMakeLists.txt b/tests/odbc/serialize/statement/CMakeLists.txt new file mode 100644 index 000000000..0ed3ff2a3 --- /dev/null +++ b/tests/odbc/serialize/statement/CMakeLists.txt @@ -0,0 +1,28 @@ +# Copyright (c) 2026, Leander Schulten +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +create_tests_group( + parameter + select +) diff --git a/tests/odbc/serialize/statement/parameter.cpp b/tests/odbc/serialize/statement/parameter.cpp new file mode 100644 index 000000000..c653b4e9a --- /dev/null +++ b/tests/odbc/serialize/statement/parameter.cpp @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +int main() { + const auto foo = test::TabFoo{}; + const auto bar = test::TabBar{}; + + // ODBC uses unnumbered '?' parameter markers. + SQLPP_COMPARE(parameter(foo.doubleN), "?"); + SQLPP_COMPARE(bar.id > parameter(foo.doubleN), "tab_bar.id > ?"); + SQLPP_COMPARE(bar.id > parameter(foo.doubleN) and + bar.textN == parameter(bar.textN), + "(tab_bar.id > ?) AND (tab_bar.text_n = ?)"); + + return 0; +} diff --git a/tests/odbc/serialize/statement/select.cpp b/tests/odbc/serialize/statement/select.cpp new file mode 100644 index 000000000..53dc5f290 --- /dev/null +++ b/tests/odbc/serialize/statement/select.cpp @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +int main() { + const auto bar = test::TabBar{}; + + SQLPP_COMPARE(select(bar.id).from(bar).where(bar.textN == "it's"), + "SELECT tab_bar.id FROM tab_bar WHERE tab_bar.text_n = " + "'it''s'"); + + SQLPP_COMPARE( + select(bar.id).from(bar).where(bar.id > parameter(bar.id)), + "SELECT tab_bar.id FROM tab_bar WHERE tab_bar.id > ?"); + + return 0; +} diff --git a/tests/odbc/usage/CMakeLists.txt b/tests/odbc/usage/CMakeLists.txt new file mode 100644 index 000000000..766a84993 --- /dev/null +++ b/tests/odbc/usage/CMakeLists.txt @@ -0,0 +1,28 @@ +# Copyright (c) 2026, Leander Schulten +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +create_tests_group( + ConnectionConfig + Statements +) diff --git a/tests/odbc/usage/ConnectionConfig.cpp b/tests/odbc/usage/ConnectionConfig.cpp new file mode 100644 index 000000000..6a7e23019 --- /dev/null +++ b/tests/odbc/usage/ConnectionConfig.cpp @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +int main() { + namespace sql = sqlpp::odbc; + + // The two ways of connecting are distinct alternatives of a variant. + sql::connection_config config; + assert(std::holds_alternative(config.source)); + + config.source = sql::data_source{.name = "test_dsn", + .username = "user", + .password = "secret"}; + assert(std::holds_alternative(config.source)); + assert(std::get(config.source).name == "test_dsn"); + + config.source = sql::connection_string{"Driver=Foo;Database=bar;"}; + assert(std::holds_alternative(config.source)); + + // Configurations compare by value (the debug logger is not compared). + sql::connection_config other; + assert(config != other); + other.source = sql::connection_string{"Driver=Foo;Database=bar;"}; + assert(config == other); + other.row_array_size = config.row_array_size + 1; + assert(config != other); + + // A default-constructed connection is not connected. + sql::connection db; + assert(not db.is_connected()); + + // Connecting to a data source that does not exist reports an ODBC error + // with SQLSTATE and diagnostic message. + try { + auto bad_config = std::make_shared(); + bad_config->source = + sql::data_source{.name = "sqlpp23_no_such_dsn", .username = {}, .password = {}}; + db.connect_using(bad_config); + std::cerr << "Expected connect to throw\n"; + return -1; + } catch (const sql::exception& e) { + // IM002: data source name not found (exact state depends on the driver + // manager, so only check that a state was reported). + assert(not e.sql_state().empty()); + } + + return 0; +} diff --git a/tests/odbc/usage/Statements.cpp b/tests/odbc/usage/Statements.cpp new file mode 100644 index 000000000..5f49a18c9 --- /dev/null +++ b/tests/odbc/usage/Statements.cpp @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, Leander Schulten + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +#include + +// Exercises all execution paths of the connector: direct and prepared +// select/insert/update/delete/execute as well as transactions. +// +// Without a configured data source this only verifies that everything +// compiles: set SQLPP_ODBC_CONNECTION_STRING to run it against a real +// database (the DDL in tables.h may need adjustment for your database). +namespace { +void run_all_statement_types(sqlpp::odbc::connection& db) { + const auto foo = test::TabFoo{}; + + test::createTabFoo(db); + + // direct execution + db(insert_into(foo).set(foo.textNnD = "direct", foo.intN = 17, + foo.doubleN = 3.5, foo.boolN = true)); + for (const auto& row : + db(select(foo.id, foo.textNnD, foo.intN, foo.doubleN, foo.boolN) + .from(foo) + .where(foo.intN.is_not_null()))) { + std::println("id: {}, text: {}, int: {}", row.id, row.textNnD, + row.intN.value_or(0)); + } + db(update(foo).set(foo.intN = 18).where(foo.textNnD == "direct")); + db(delete_from(foo).where(foo.intN == 18)); + + // prepared execution + auto prepared_insert = db.prepare(insert_into(foo).set( + foo.textNnD = parameter(foo.textNnD), foo.intN = parameter(foo.intN))); + prepared_insert.parameters.textNnD = "prepared"; + prepared_insert.parameters.intN = 42; + db(prepared_insert); + prepared_insert.parameters.intN = std::nullopt; // NULL + db(prepared_insert); + + auto prepared_select = db.prepare( + select(foo.id, foo.textNnD).from(foo).where(foo.id > parameter(foo.id))); + prepared_select.parameters.id = 0; + for (const auto& row : db(prepared_select)) { + std::println("id: {}, text: {}", row.id, row.textNnD); + } + + auto prepared_update = db.prepare( + update(foo).set(foo.intN = 43).where(foo.textNnD == parameter(foo.textNnD))); + prepared_update.parameters.textNnD = "prepared"; + db(prepared_update); + + auto prepared_delete = db.prepare( + delete_from(foo).where(foo.textNnD == parameter(foo.textNnD))); + prepared_delete.parameters.textNnD = "prepared"; + db(prepared_delete); + + // transactions + { + auto tx = start_transaction(db); + db(insert_into(foo).set(foo.textNnD = "in transaction")); + tx.commit(); + } + { + auto tx = start_transaction(db); + db(insert_into(foo).set(foo.textNnD = "rolled back")); + tx.rollback(); + } +} +} // namespace + +int main() { + if (std::getenv("SQLPP_ODBC_CONNECTION_STRING") == nullptr) { + std::println( + "ODBC: skipping statement execution, SQLPP_ODBC_CONNECTION_STRING is " + "not set"); + return 0; + } + + try { + auto db = sqlpp::odbc::make_test_connection(); + run_all_statement_types(db); + } catch (const std::exception& e) { + std::println(stderr, "Exception: {}", e.what()); + return -1; + } + + return 0; +} From cfe43208fcfea993d5a0adf32e95fef1e806aa3a Mon Sep 17 00:00:00 2001 From: autoantwort Date: Tue, 14 Jul 2026 13:46:47 +0200 Subject: [PATCH 2/3] Changes from testing against sqlite odbc connector Verified end-to-end against the SQLite ODBC driver (`brew install sqliteodbc`): - All data types round trip (bool, int64, uint64, double, text with embedded quotes, 100 KB blob, date/time/timestamp) via both direct execution (literals) and prepared statements (bound parameters). - NULL values in both directions. - Block fetch across rowset boundaries (`row_array_size = 3`, 10 rows). - The `SQLGetData` streaming fallback including the buffer-growth loop. - Transactions: commit and rollback verified by row counts. - `affected_rows` for update/delete. Live testing caught a real-world quirk: the SQLite driver reports `blob` columns as `SQL_BINARY` with a fabricated size of 255, so blob result columns (and long-typed columns) now always use the streaming path instead of trusting driver-reported sizes. Bound timestamp parameters are rounded to milliseconds by that driver; the connector itself preserves microseconds. To run the tests (serialization/config tests need no driver; the statement test runs against a real database only when the environment variable is set): cmake -S . -B build -DBUILD_ODBC_CONNECTOR=ON cmake --build build SQLPP_ODBC_CONNECTION_STRING="Driver=/opt/homebrew/lib/libsqlite3odbc.dylib;Database=/tmp/sqlpp23_odbc_test.db;" \ ctest --test-dir build -R odbc Not covered: the `BUILD_WITH_MODULES` variant of `sqlpp23.odbc.cppm` cannot be built on this machine (no `clang-scan-deps`; this equally affects the existing core/sqlite3 modules). Co-Authored-By: Claude Fable 5 --- docs/connectors/odbc.md | 44 ++++- include/sqlpp23/odbc/cursor_result.h | 34 ++-- tests/odbc/usage/Statements.cpp | 264 +++++++++++++++++++++++---- 3 files changed, 289 insertions(+), 53 deletions(-) diff --git a/docs/connectors/odbc.md b/docs/connectors/odbc.md index f940b0777..ba34dd014 100644 --- a/docs/connectors/odbc.md +++ b/docs/connectors/odbc.md @@ -47,14 +47,18 @@ Select results are read through an ODBC block cursor where possible: the connector binds the result columns to fixed-size buffers and fetches `connection_config::row_array_size` rows per driver round trip (default: 64). -This requires that the size of each row is known up front. Text and blob -columns qualify if their declared maximum size fits into +This requires that the size of each row is known up front. Text columns +qualify if their declared maximum size fits into `connection_config::max_bound_column_size` bytes per row (default: 4096, -text columns are budgeted with 4 bytes per character for UTF-8). If any -column exceeds the limit — or the driver cannot tell its size, as with -unbounded `TEXT` columns — the result transparently falls back to fetching -one row at a time and streaming each value with `SQLGetData`, which has no -size limit. +budgeted with 4 bytes per character for UTF-8). If any column exceeds the +limit — or its size cannot be trusted, as with unbounded `TEXT` columns and +blobs — the result transparently falls back to fetching one row at a time +and streaming each value with `SQLGetData`, which has no size limit. + +Setting `max_bound_column_size = 0` always streams results that contain +text or blob columns. This is useful for databases that do not enforce +declared column sizes (e.g. SQLite, where a `varchar(255)` column may hold +longer values). ```c++ auto config = std::make_shared(); @@ -88,6 +92,32 @@ connector does not offer `last_insert_id`. Use a select with the appropriate function of your database (e.g. `last_insert_rowid()`, `lastval()`, `SCOPE_IDENTITY()`) if you need it. +## Running the tests + +The ODBC tests live in `tests/odbc` and are built with +`-DBUILD_ODBC_CONNECTOR=ON`. The serialization and configuration tests run +without any ODBC driver: + +```sh +cmake -S . -B build -DBUILD_ODBC_CONNECTOR=ON +cmake --build build +ctest --test-dir build -R odbc +``` + +The statement test (`sqlpp23.odbc.usage.Statements`) only checks compilation +by default. When `SQLPP_ODBC_CONNECTION_STRING` is set, it additionally runs +all statement types against the configured database and verifies the results, +e.g. with the serverless [SQLite ODBC driver](http://www.ch-werner.de/sqliteodbc/) +(`brew install sqliteodbc`): + +```sh +SQLPP_ODBC_CONNECTION_STRING="Driver=/opt/homebrew/lib/libsqlite3odbc.dylib;Database=/tmp/sqlpp23_odbc_test.db;" \ + ctest --test-dir build -R odbc +``` + +The DDL in `tests/include/sqlpp23/tests/odbc/tables.h` is written for SQLite; +it may need adjustment for other databases. + ## Exceptions In exceptional situations the connector throws `sqlpp::odbc::exception` which diff --git a/include/sqlpp23/odbc/cursor_result.h b/include/sqlpp23/odbc/cursor_result.h index 88283f916..a0cfbd4c4 100644 --- a/include/sqlpp23/odbc/cursor_result.h +++ b/include/sqlpp23/odbc/cursor_result.h @@ -37,6 +37,7 @@ #include #include +#include #include #include @@ -272,6 +273,13 @@ class cursor_result_t { if (column.buffer_size != 0) { continue; // fixed-size column } + if (column.type == column_type::blob) { + // Blob values are typically large and drivers do not reliably report + // sizes for binary columns (e.g. the SQLite driver claims 255 bytes + // for any blob), so blobs are always streamed. + all_columns_bindable = false; + continue; + } SQLULEN declared_size{0}; SQLSMALLINT sql_type{}, decimal_digits{}, nullable{}, name_length{}; SQLCHAR name[256]; @@ -281,12 +289,15 @@ class cursor_result_t { &declared_size, &decimal_digits, &nullable), "ODBC: could not describe result column", SQL_HANDLE_STMT, native_handle()); + // Values of the long types can exceed the reported column size, e.g. + // unbounded TEXT columns. + const bool unbounded_type = sql_type == SQL_LONGVARCHAR or + sql_type == SQL_WLONGVARCHAR or + sql_type == SQL_LONGVARBINARY; // Text sizes are reported in characters which can take up to 4 bytes // each (UTF-8), plus the terminating NUL. - const size_t required_bytes = column.type == column_type::text - ? declared_size * 4 + 1 - : declared_size; - if (declared_size == 0 or + const size_t required_bytes = declared_size * 4 + 1; + if (unbounded_type or declared_size == 0 or required_bytes > _config->max_bound_column_size) { all_columns_bindable = false; } else { @@ -406,16 +417,15 @@ class cursor_result_t { if (indicator == SQL_NULL_DATA) { return {}; } - // Text buffers reserve one byte for the terminating NUL. - const size_t max_value_size = column.type == column_type::text - ? column.buffer_size - 1 - : column.buffer_size; + // The buffer reserves one byte for the terminating NUL. Only text + // columns are bound (blobs are always streamed). if (indicator == SQL_NO_TOTAL or - static_cast(indicator) > max_value_size) { + static_cast(indicator) >= column.buffer_size) { throw exception{ - "ODBC: result value did not fit into the bound column buffer; " - "increase connection_config::max_bound_column_size or fix the " - "column's declared size", + "ODBC: a text value was longer than the column's declared maximum " + "size suggests; fix the column's declared size or set " + "connection_config::max_bound_column_size to 0 to always stream " + "variable-size columns", "01004"}; } return {column.buffer.data() + _current_row * column.buffer_size, diff --git a/tests/odbc/usage/Statements.cpp b/tests/odbc/usage/Statements.cpp index 5f49a18c9..eb60d98f9 100644 --- a/tests/odbc/usage/Statements.cpp +++ b/tests/odbc/usage/Statements.cpp @@ -24,72 +24,246 @@ * POSSIBILITY OF SUCH DAMAGE. */ +#include #include +#include +#include #include -// Exercises all execution paths of the connector: direct and prepared -// select/insert/update/delete/execute as well as transactions. +// Exercises the execution paths of the connector: direct and prepared +// select/insert/update/delete, all data types, NULL values, block fetch +// across rowset boundaries, the SQLGetData streaming fallback, and +// transactions. // // Without a configured data source this only verifies that everything // compiles: set SQLPP_ODBC_CONNECTION_STRING to run it against a real // database (the DDL in tables.h may need adjustment for your database). namespace { -void run_all_statement_types(sqlpp::odbc::connection& db) { + +#define expect(condition) \ + if (not(condition)) { \ + throw std::runtime_error{std::format("{}:{}: check failed: {}", __FILE__, \ + __LINE__, #condition)}; \ + } + +template +size_t count_rows(Db& db) { const auto foo = test::TabFoo{}; + size_t count = 0; + for (const auto& row : db(select(foo.id).from(foo))) { + (void)row; + ++count; + } + return count; +} +void test_data_type_round_trips(sqlpp::odbc::connection& db) { + const auto foo = test::TabFoo{}; test::createTabFoo(db); - // direct execution - db(insert_into(foo).set(foo.textNnD = "direct", foo.intN = 17, - foo.doubleN = 3.5, foo.boolN = true)); + auto blob_value = std::vector(100 * 1000); + for (size_t index = 0; index < blob_value.size(); ++index) { + blob_value[index] = static_cast(index % 251); + } + + // Insert with direct execution (values become literals), read back. + db(insert_into(foo).set(foo.textNnD = "it's a text", foo.intN = -42, + foo.doubleN = 1.25, + foo.uIntN = uint64_t{1234567890123456789}, + foo.boolN = true, foo.blobN = blob_value)); + + size_t row_count = 0; for (const auto& row : - db(select(foo.id, foo.textNnD, foo.intN, foo.doubleN, foo.boolN) - .from(foo) - .where(foo.intN.is_not_null()))) { - std::println("id: {}, text: {}, int: {}", row.id, row.textNnD, - row.intN.value_or(0)); + db(select(all_of(foo)).from(foo).where(foo.textNnD == "it's a text"))) { + ++row_count; + expect(row.textNnD == "it's a text"); + expect(row.intN == -42); + expect(row.doubleN == 1.25); + expect(row.uIntN == uint64_t{1234567890123456789}); + expect(row.boolN == true); + expect(row.blobN.has_value()); + expect(std::ranges::equal(*row.blobN, blob_value)); } - db(update(foo).set(foo.intN = 18).where(foo.textNnD == "direct")); - db(delete_from(foo).where(foo.intN == 18)); + expect(row_count == 1); - // prepared execution + // Insert the same values as parameters of a prepared statement. auto prepared_insert = db.prepare(insert_into(foo).set( - foo.textNnD = parameter(foo.textNnD), foo.intN = parameter(foo.intN))); + foo.textNnD = parameter(foo.textNnD), foo.intN = parameter(foo.intN), + foo.doubleN = parameter(foo.doubleN), foo.uIntN = parameter(foo.uIntN), + foo.boolN = parameter(foo.boolN), foo.blobN = parameter(foo.blobN))); prepared_insert.parameters.textNnD = "prepared"; - prepared_insert.parameters.intN = 42; - db(prepared_insert); - prepared_insert.parameters.intN = std::nullopt; // NULL + prepared_insert.parameters.intN = -43; + prepared_insert.parameters.doubleN = 2.5; + prepared_insert.parameters.uIntN = uint64_t{987654321}; + prepared_insert.parameters.boolN = false; + prepared_insert.parameters.blobN = blob_value; db(prepared_insert); - auto prepared_select = db.prepare( - select(foo.id, foo.textNnD).from(foo).where(foo.id > parameter(foo.id))); - prepared_select.parameters.id = 0; + auto prepared_select = + db.prepare(select(all_of(foo)) + .from(foo) + .where(foo.textNnD == parameter(foo.textNnD))); + prepared_select.parameters.textNnD = "prepared"; + row_count = 0; for (const auto& row : db(prepared_select)) { - std::println("id: {}, text: {}", row.id, row.textNnD); + ++row_count; + expect(row.intN == -43); + expect(row.doubleN == 2.5); + expect(row.uIntN == uint64_t{987654321}); + expect(row.boolN == false); + expect(row.blobN.has_value()); + expect(std::ranges::equal(*row.blobN, blob_value)); } + expect(row_count == 1); - auto prepared_update = db.prepare( - update(foo).set(foo.intN = 43).where(foo.textNnD == parameter(foo.textNnD))); - prepared_update.parameters.textNnD = "prepared"; - db(prepared_update); + // NULL values, direct and as parameters. + db(update(foo) + .set(foo.intN = std::nullopt, foo.doubleN = std::nullopt, + foo.uIntN = std::nullopt, foo.boolN = std::nullopt, + foo.blobN = std::nullopt) + .where(foo.textNnD == "it's a text")); + prepared_insert.parameters.textNnD = "nulls"; + prepared_insert.parameters.intN = std::nullopt; + prepared_insert.parameters.doubleN = std::nullopt; + prepared_insert.parameters.uIntN = std::nullopt; + prepared_insert.parameters.boolN = std::nullopt; + prepared_insert.parameters.blobN = std::nullopt; + db(prepared_insert); - auto prepared_delete = db.prepare( - delete_from(foo).where(foo.textNnD == parameter(foo.textNnD))); - prepared_delete.parameters.textNnD = "prepared"; - db(prepared_delete); + for (const auto& row : + db(select(all_of(foo)) + .from(foo) + .where(foo.textNnD == "it's a text" or foo.textNnD == "nulls"))) { + expect(row.intN == std::nullopt); + expect(row.doubleN == std::nullopt); + expect(row.uIntN == std::nullopt); + expect(row.boolN == std::nullopt); + expect(row.blobN == std::nullopt); + } + + // Update and delete report affected rows. + expect(db(update(foo).set(foo.intN = 1).where(foo.textNnD == "nulls")) + .affected_rows == 1); + expect(db(delete_from(foo).where(foo.textNnD == "nulls")).affected_rows == 1); +} + +void test_date_time_round_trips(sqlpp::odbc::connection& db) { + using namespace std::chrono_literals; + const auto tab = test::TabDateTime{}; + test::createTabDateTime(db); + + const auto date_value = + static_cast(std::chrono::February / 8 / 2025); + // The connector preserves microseconds, but some drivers round timestamp + // parameters (e.g. the SQLite driver stores milliseconds), so use a + // millisecond-precision value to keep this test portable. + const auto timestamp_value = date_value + 10h + 9min + 8s + 123ms; + // The ODBC time type has no sub-second precision. + const auto time_value = 10h + 9min + 8s; + + // Insert as literals ({d ...}, {ts ...}, {t ...} escapes). + db(insert_into(tab).set(tab.dateN = date_value, + tab.timestampN = timestamp_value, + tab.timeN = std::chrono::microseconds{time_value})); + + // Insert the same values as parameters (SQL_C_TYPE_* structs). + auto prepared_insert = db.prepare( + insert_into(tab).set(tab.dateN = parameter(tab.dateN), + tab.timestampN = parameter(tab.timestampN), + tab.timeN = parameter(tab.timeN))); + prepared_insert.parameters.dateN = date_value; + prepared_insert.parameters.timestampN = timestamp_value; + prepared_insert.parameters.timeN = std::chrono::microseconds{time_value}; + db(prepared_insert); + + size_t row_count = 0; + for (const auto& row : + db(select(tab.dateN, tab.timestampN, tab.timeN).from(tab))) { + ++row_count; + expect(row.dateN == date_value); + expect(row.timestampN == timestamp_value); + expect(row.timeN == time_value); + } + expect(row_count == 2); +} + +// Fetching more rows than fit into one rowset must cross rowset boundaries +// correctly (db is configured with a small row_array_size). +void test_multiple_rowsets(sqlpp::odbc::connection& db) { + const auto foo = test::TabFoo{}; + test::createTabFoo(db); + + auto prepared_insert = db.prepare(insert_into(foo).set( + foo.textNnD = "row", foo.intN = parameter(foo.intN))); + constexpr int64_t row_count = 10; + for (int64_t i = 0; i < row_count; ++i) { + prepared_insert.parameters.intN = i; + db(prepared_insert); + } + + // varchar(255) is bindable, so this uses block fetch, crossing rowset + // boundaries and reading text from the bound column arrays. + int64_t expected = 0; + for (const auto& row : + db(select(foo.textNnD, foo.intN).from(foo).order_by(foo.intN.asc()))) { + expect(row.textNnD == "row"); + expect(row.intN == expected); + ++expected; + } + expect(expected == row_count); +} + +// With a tiny max_bound_column_size, text columns cannot be bound and the +// result falls back to streaming values via SQLGetData (db is configured +// accordingly). Values larger than the initial streaming buffer exercise +// the buffer growth loop. +void test_streamed_text(sqlpp::odbc::connection& db) { + const auto foo = test::TabFoo{}; + test::createTabFoo(db); + + const auto long_text = std::string(5000, 'x') + "end"; + auto prepared_insert = db.prepare( + insert_into(foo).set(foo.textNnD = parameter(foo.textNnD), foo.intN = 7)); + prepared_insert.parameters.textNnD = long_text; + db(prepared_insert); + db(insert_into(foo).set(foo.textNnD = "", foo.intN = std::nullopt)); + + size_t row_count = 0; + for (const auto& row : + db(select(foo.textNnD, foo.intN).from(foo).order_by(foo.id.asc()))) { + if (row_count == 0) { + expect(row.textNnD == long_text); + expect(row.intN == 7); + } else { + expect(row.textNnD == ""); + expect(row.intN == std::nullopt); + } + ++row_count; + } + expect(row_count == 2); +} + +void test_transactions(sqlpp::odbc::connection& db) { + const auto foo = test::TabFoo{}; + test::createTabFoo(db); - // transactions { auto tx = start_transaction(db); - db(insert_into(foo).set(foo.textNnD = "in transaction")); + db(insert_into(foo).set(foo.textNnD = "committed")); tx.commit(); } + expect(count_rows(db) == 1); + expect(not db.is_transaction_active()); + { auto tx = start_transaction(db); db(insert_into(foo).set(foo.textNnD = "rolled back")); + expect(db.is_transaction_active()); tx.rollback(); } + expect(count_rows(db) == 1); + expect(not db.is_transaction_active()); } } // namespace @@ -102,12 +276,34 @@ int main() { } try { - auto db = sqlpp::odbc::make_test_connection(); - run_all_statement_types(db); + { + auto db = sqlpp::odbc::make_test_connection(); + expect(db.is_connected()); + test_data_type_round_trips(db); + test_date_time_round_trips(db); + test_transactions(db); + } + { + // Small rowsets to test fetching across rowset boundaries. + auto config = sqlpp::odbc::make_test_config(); + config->row_array_size = 3; + sqlpp::odbc::connection db; + db.connect_using(config); + test_multiple_rowsets(db); + } + { + // Tiny column-buffer limit to force the SQLGetData streaming fallback. + auto config = sqlpp::odbc::make_test_config(); + config->max_bound_column_size = 8; + sqlpp::odbc::connection db; + db.connect_using(config); + test_streamed_text(db); + } } catch (const std::exception& e) { std::println(stderr, "Exception: {}", e.what()); return -1; } + std::println("ODBC: all statement tests passed"); return 0; } From ab83180b03389660b643cedfa67fcc796a21c98e Mon Sep 17 00:00:00 2001 From: autoantwort Date: Tue, 14 Jul 2026 13:52:48 +0200 Subject: [PATCH 3/3] Add handoff to continue on windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For the chat: `Read ODBC_HANDOFF.md — continue testing the ODBC connector with Windows drivers.` --- ODBC_HANDOFF.md | 131 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 ODBC_HANDOFF.md diff --git a/ODBC_HANDOFF.md b/ODBC_HANDOFF.md new file mode 100644 index 000000000..b2f04024a --- /dev/null +++ b/ODBC_HANDOFF.md @@ -0,0 +1,131 @@ +# ODBC connector — session handoff + +Context document for continuing work on the sqlpp23 ODBC connector on +another machine. Written after the initial implementation on macOS +(2026-07-14). Tell Claude "Read ODBC_HANDOFF.md" at the start of the next +session. This file can be deleted once the cross-platform testing is done. + +## State + +- Complete new ODBC connector in `include/sqlpp23/odbc/`, enabled with + `-DBUILD_ODBC_CONNECTOR=ON` (CMake's built-in `FindODBC`). +- All 136 tests pass on macOS (unixODBC 2.3.14, Homebrew); the 8 ODBC tests + also pass *live* against the SQLite ODBC driver (`brew install sqliteodbc`). +- The `BUILD_WITH_MODULES` variant of `modules/sqlpp23.odbc.cppm` is + **unvalidated**: no `clang-scan-deps` on the Mac (this equally affects the + existing core/sqlite3 modules). Worth trying on Windows with MSVC. +- Docs: `docs/connectors/odbc.md` (includes how to run the tests). + +## File map + +``` +include/sqlpp23/odbc/ + odbc.h umbrella header + cursor_result.h result type (block cursor + SQLGetData streaming) + prepared_statement.h SQLPrepare/SQLBindParameter, param buffers + to_sql_string.h '?' params, {d}/{t}/{ts} escape sequences + detail/diagnostics.h throw_on_error / make_exception (SQLGetDiagRec) + database/connection.h connection_base, transactions, isolation levels + database/connection_config.h variant + tuning + database/connection_handle.h env (ODBC 3.8) + dbc RAII, SQLConnect/SQLDriverConnect + database/exception.h exception with sql_state() + native_error_code() + database/connection_pool.h, database/serializer_context.h +modules/sqlpp23.odbc.cppm +tests/odbc/ serialize (offline), usage (ConnectionConfig, Statements) +tests/include/sqlpp23/tests/odbc/ tables.h (SQLite-flavored DDL!), helpers +``` + +## Key design decisions + +- **One result class** (`cursor_result_t`), two fetch strategies decided at + first `next()` after the row's `bind_field` calls record the column plan: + - **Block cursor**: all columns bindable → `SQLBindCol` column-wise arrays, + `SQL_ATTR_ROW_ARRAY_SIZE = connection_config::row_array_size` (default 64), + `SQL_ATTR_ROWS_FETCHED_PTR` (heap-allocated so moves are safe). Handles + drivers that lower/reject the rowset size. + - **Streaming**: any blob column, any long-typed column (`SQL_LONGVARCHAR`, + `SQL_WLONGVARCHAR`, `SQL_LONGVARBINARY`), unknown size, or text wider than + `connection_config::max_bound_column_size` (default 4096; text budgeted + ×4 for UTF-8 +1 NUL) → row-at-a-time, `SQLGetData` per column in + increasing index order (the only pattern *every* driver supports), + growing buffer with the standard truncation/`SQL_NO_TOTAL` loop. + - All-or-nothing: mixing bound arrays and SQLGetData needs `SQLSetPos` / + `SQL_GD_BLOCK`, which is driver-specific — deliberately avoided. +- Statement handles are `shared_ptr`; a result from a prepared + statement shares ownership. Result dtor does `SQL_CLOSE` + `SQL_UNBIND` and + resets `ROWS_FETCHED_PTR`/`ROW_ARRAY_SIZE` so the prepared statement can be + re-executed. +- Transactions: no `BEGIN` in ODBC — autocommit off + `SQLEndTran`; isolation + via `SQL_ATTR_TXN_ISOLATION` (persists across transactions; documented). +- Parameters: scalars/chrono copied into per-index `parameter_data` (vector + sized once at prepare from the serializer's param count — never resized, + addresses are registered with the driver); text/blob bound by pointer into + the core prepared statement's storage (same lifetime guarantee MySQL + connector relies on). NULL = `SQL_C_DEFAULT`/`SQL_VARCHAR` + `SQL_NULL_DATA`. +- No `last_insert_id` (nothing portable in ODBC). No `constraints.h` + (generic ODBC can't know backend capabilities at compile time). +- `escape()` doubles single quotes without needing a live connection — that's + why serialize tests run offline (`SQLPP_COMPARE` uses an unconnected + `connection`). + +## Lessons learned (from live testing with sqliteodbc) + +- **Do not trust `SQLDescribeCol` sizes for binary columns**: sqliteodbc + reports every `blob` as `SQL_BINARY` size 255. Originally blobs were + block-bound by declared size; a 100 KB blob tripped the truncation guard. + Fix: blobs and long types always stream. +- sqliteodbc reports unbounded `TEXT` as `SQL_LONGVARCHAR` with fake size + 65536 → the long-type check catches it. +- sqliteodbc rounds bound **timestamp parameters to milliseconds** (literals + and reads keep microseconds — reads return correct nanosecond `fraction`). + `Statements.cpp` therefore uses a millisecond-precision test value. +- sqliteodbc supports block cursors (rowset sizes 3 and 64 verified). +- unixODBC quirk: attribute buffers for "32-bit" attributes are read into + `SQLULEN`-sized variables defensively (some drivers write 64 bits). +- `assert`-style checks in `Statements.cpp` use a custom `expect` macro that + throws (works in Release builds too). + +## How to run the tests + +```sh +cmake -S . -B build -DBUILD_ODBC_CONNECTOR=ON +cmake --build build +ctest --test-dir build -R odbc # offline part +# Live part (any ODBC driver; example: SQLite driver on macOS): +SQLPP_ODBC_CONNECTION_STRING="Driver=/opt/homebrew/lib/libsqlite3odbc.dylib;Database=/tmp/sqlpp23_odbc_test.db;" \ + ctest --test-dir build -R odbc +``` + +`tests/odbc/usage/Statements.cpp` is the integration test: it skips (returns +0) without `SQLPP_ODBC_CONNECTION_STRING`, otherwise asserts round trips of +all data types, NULLs, rowset boundaries, streaming, and transactions. + +## TODO / next steps on Windows + +- Windows has its own driver manager (`odbc32.lib`) — `find_package(ODBC)` + should just work; no unixODBC needed. +- Test against real drivers, e.g.: + - SQL Server: `Driver={ODBC Driver 18 for SQL Server};Server=...;Database=...;Uid=...;Pwd=...;TrustServerCertificate=yes;` + - MySQL: `Driver={MySQL ODBC 9.x Unicode Driver};Server=...;Database=...;User=...;Password=...;` + - PostgreSQL: `Driver={PostgreSQL Unicode};Server=...;Database=...;Uid=...;Pwd=...;` + - SQLite: `Driver=SQLite3 ODBC Driver;Database=c:\path\test.db;` +- **The test DDL is SQLite-flavored** (`INTEGER PRIMARY KEY AUTOINCREMENT` in + `tests/include/sqlpp23/tests/odbc/tables.h` / `tables.sql`) — needs + per-backend DDL to run `Statements.cpp` against other databases. +- Things to watch on Windows / other drivers: + - The connector uses the **narrow (ANSI) ODBC API**. On Windows the driver + manager converts to the W-API using the ANSI code page — non-ASCII text + may mangle unless the process code page is UTF-8. Possible follow-up: + optional wide-char (`SQLWCHAR`) support. + - MSVC: repo targets C++23; `not`/`and` alternative tokens are used + throughout (core does the same) — needs `/permissive-` (default with + recent MSVC standards flags). + - SQL Server: `time` columns are `SQL_SS_TIME2` (driver-specific type) — + check that binding `SQL_C_TYPE_TIME` still works; `datetimeoffset` is not + supported by the connector. + - Check `SQL_C_UBIGINT` support on each driver (used for unsigned bigint). + - Verify the truncation guard doesn't fire on drivers that report character + column sizes differently (we budget ×4 bytes/char for UTF-8). +- Consider CI for the ODBC build (e.g. Linux + unixODBC + sqliteodbc, and a + Windows job). +- `BUILD_WITH_MODULES` build of `sqlpp23.odbc.cppm` still unvalidated.