Lightweight SQL client is non-intrusively extensible with respect to custom column data types.
A lot of standard types are already supported, but if you need to add a custom type, you can do it by implementing a simple interface.
Suppose you have a custom type CustomType that you want to bind to the SQL statement.
struct CustomType
{
int value;
constexpr auto operator<=>(CustomType const&) const noexcept = default;
};To bind the custom type to the SQL statement, you need to implement the SqlDataBinder<> specialization
for the custom type.
template <>
struct SqlDataBinder<CustomType>
{
/// Define the column type for the custom type, e.g. SqlColumnTypeDefinitions::Guid {}.
static constexpr auto ColumnType = SqlDataBinder<decltype(CustomType::value)>::ColumnType;
/// Binds the custom type to the SQL statement as an input parameter.
static SQLRETURN InputParameter(SQLHSTMT hStmt,
SQLUSMALLINT column,
CustomType const& value,
SqlDataBinderCallback& cb) noexcept
{
return SqlDataBinder<int>::InputParameter(hStmt, column, value.value, cb);
}
/// Binds the custom type to the SQL statement as an output parameter.
///
/// This function is used to bind the custom type to the SQL statement as an output parameter,
/// which means that the SQL statement will return the custom type's value into the `result` parameter.
///
/// @param hStmt The SQL statement handle.
/// @param column The column number.
/// @param result The custom type to bind to the SQL statement.
/// @param indicator The indicator, which is used to determine whether the value is NULL.
/// @param callback The callback that is called after the binding process is completed.
///
/// @see SqlDataBinderCallback
static SQLRETURN OutputColumn(SQLHSTMT hStmt,
SQLUSMALLINT column,
CustomType* result,
SQLLEN* indicator,
SqlDataBinderCallback& callback) noexcept
{
return SqlDataBinder<int>::OutputColumn(hStmt, column, &result->value, indicator, callback);
}
/// Retrieves the custom type's value from the SQL statement's currnt result row at the given column.
///
/// This function is used to retrieve the custom type's value from the SQL statement's current result row at the given column.
///
/// @param hStmt The SQL statement handle.
/// @param column The column number.
/// @param result The custom type to bind to the SQL statement.
/// @param indicator The indicator, which is used to determine whether the value is NULL.
/// @param callback The callback that can be used to access some runtime information.
///
/// @see SqlDataBinderCallback
static SQLRETURN GetColumn(SQLHSTMT hStmt,
SQLUSMALLINT column,
CustomType* result,
SQLLEN* indicator,
SqlDataBinderCallback const& cb) noexcept
{
return SqlDataBinder<int>::GetColumn(hStmt, column, &result->value, indicator, cb);
}
/// Provides a human-readable representation of the custom type.
///
/// This is used purely for debugging purposes.
static std::string Inspect(CustomType const& value) noexcept
{
return std::format("CustomType({})", value.value);
}
};The InputParameter() function binds the custom type to the SQL statement as an input parameter.
It is usually sufficient to call one of the already existing SqlDataBinder<T>::InputParameter(...)
functions for the underlying type.
Sometimes you may need to perform additional operations, such as converting the custom type to the
underlying type, before calling the SqlDataBinder<T>::InputParameter(...) function. Then you can
make use of the passed callback to also make sure any additional operations are performed
at the end of the binding process.
The OutputColumn() function binds the custom type to the SQL statement as an output parameter.
It is usually sufficient to call one of the already existing SqlDataBinder<T>::OutputColumn(...)
The GetColumn() function retrieves the custom type from the SQL statement.
Calling GetColumn() is usually less efficient than calling OutputColumn() because it requires
an additional copy operation.
The Inspect() function is used to provide a human-readable representation of the custom type.
This function should be used purely for debugging purposes.
Precision is a count of decimal digits. It may be at most
Lightweight::SqlMaxNumericPrecision, which is 19 on every supported toolchain:
| Toolchain | SqlMaxNumericPrecision |
Widest column |
|---|---|---|
GCC / Clang (native __int128) |
19 | DECIMAL(19, s) — including MS SQL Server's money |
MSVC, clang-cl (software Int128Soft) |
19 | DECIMAL(19, s) — including MS SQL Server's money |
The bound is deliberately toolchain-independent. A column's precision comes from the
database schema, so a ddl2cpp-generated record has to compile wherever it is consumed —
a header that builds under GCC and fails under MSVC would make the generator useless.
Lightweight::Int128, the type that carries the unscaled value, is __int128_t where the
compiler has one and a software stand-in (detail::Int128Soft) where it does not.
Scale counts the digits after the decimal point and may be anywhere in
[0, Precision]. Scale == Precision is the purely fractional case: SqlNumeric<4, 4>
is SQL's DECIMAL(4, 4) and covers [0.0000, 0.9999].
Note that SQL_MAX_NUMERIC_LEN (16) is a byte count, not a digit count — do not use
it as a precision bound. It is the size of SQL_NUMERIC_STRUCT::val, a 128-bit
mantissa, which is 38 decimal digits. 38 is not the bound either: it is what the
ODBC struct could hold, not what this implementation can read back. What narrows it is
the readable width — every accessor except ToUnscaledValue() and ToString()
divides through long double, so no more digits can be read back through those than that
type's significand holds. The widest long double in use is the 80-bit x87 one, whose
64-bit significand gives 19 digits; past that nothing is readable on any platform — a
fetched SqlNumeric<20, 0> holding 99999999999999999999 would come back as
100000000000000000000.
A column wider than the bound must be read as a string.
Even inside the bound the accessors do not all carry the same number of digits. For a value obtained by fetching (i.e. one whose mantissa arrived intact):
| Accessor | Significant digits | Notes |
|---|---|---|
ToUnscaledValue() |
up to Precision |
Returns Lightweight::Int128. The only accessor that yields the raw integer. |
ToString() |
up to Precision |
Rendered from the unscaled integer, not by formatting a long double, so every digit survives on every toolchain. |
ToLongDouble() |
15 guaranteed; up to 19 in practice | Bounded by long double: at most 19 (x87 80-bit); 15 where long double is a double (MSVC, Clang on Apple Silicon). |
ToDouble(), operator<=> |
15 | std::numeric_limits<double>::digits10. |
ToFloat(), operator== |
7 | std::numeric_limits<float>::digits10. operator== compares via ToFloat(), so SqlNumeric<19, 4> values 1234567890.1234 and 1234567890.9999 compare equal. Compare ToUnscaledValue() if you need exactness. |
So ToUnscaledValue() and ToString() are exact to the declared precision everywhere,
including MSVC; the floating-point accessors are not. If you need more than 15 digits out
of a SqlNumeric, use one of those two rather than ToDouble().
Neither Int128 implementation is formattable by std::format (the native __int128_t
is not either), so render an unscaled value with
Lightweight::detail::Int128ToString(value.ToUnscaledValue()), or just use ToString().
Declaring a wide Precision does not by itself guarantee that every digit is
transferred. What you can rely on:
-
Up to
std::numeric_limits<double>::digits10(15) significant decimal digits: exact everywhere. Every backend and driver combination round-trips such a value unchanged. Beyond that, note that narrowing to 15 significant digits can round into the integral part —123456789012345.6789becomes123456789012346— so a wide value is not merely "right up to the decimal point". -
Beyond 15 significant digits: only on the native
SQL_C_NUMERICpath. Two independent things can narrow the value to adouble(≈15 significant digits):- The binder deliberately falls back to
SQL_C_DOUBLEfor SQLite and MS SQL Server, whose drivers do not handleSQL_NUMERIC_STRUCTusably (seeNativeNumericSupportIsBroken). This is why MS SQL Server's ownmoneyloses precision on MS SQL Server: its maximum922337203685477.5807reads back as922337203685477.6250. - Some driver builds narrow internally even on the native path. The psqlODBC that
ships for Windows converts through a
doublebefore fillingSQL_NUMERIC_STRUCT, so a 19-digit value comes back as the mantissa of its nearestdouble; the Linux build of the same driver transfers the mantissa verbatim.
PostgreSQL on Linux is therefore the only combination that carries a full 19-digit value end to end today.
- The binder deliberately falls back to
One further limit is independent of the driver: assigning from a float/double — the
only value-setting API besides fetching — is bounded by that floating-point type, so
SqlNumeric<19, 4> { 922337203685477.5807 } already holds 922337203685477.6250
before any database is involved.
If you need exactness above 15 digits on an arbitrary backend, read the column as a string instead.
SqlDataBinder<SqlVariant>::GetColumn queries the driver for
SQL_DESC_CONCISE_TYPE and dispatches into the matching variant alternative
based on the concise ODBC SQL type code.
The verbose SQL_DESC_TYPE collapses every datetime subtype into the single
value SQL_DATETIME (9) and every interval subtype into SQL_INTERVAL (10), so
it cannot distinguish DATE from TIME from TIMESTAMP. The concise type does:
| Column kind | SQL_DESC_CONCISE_TYPE |
Variant alternative |
|---|---|---|
| DATE | SQL_TYPE_DATE (91) |
SqlDate |
| TIME | SQL_TYPE_TIME (92) |
SqlTime |
| TIMESTAMP | SQL_TYPE_TIMESTAMP (93) |
SqlDateTime |
| DECIMAL / NUMERIC | SQL_DECIMAL (3), SQL_NUMERIC (2) |
SqlDynamicNumeric |
| BINARY / VARBINARY / LONGVARBINARY | SQL_BINARY (-2), SQL_VARBINARY (-3), SQL_LONGVARBINARY (-4) |
SqlBinary |
SqlNumeric<Precision, Scale> fixes both at compile time. A value arriving through a
SqlVariant learns its precision and scale from the result-set metadata instead, so
DECIMAL/NUMERIC columns fill a SqlDynamicNumeric, which carries them alongside the value.
The number is stored as the unscaled integer value * 10^scale and never passes through a
binary floating-point type:
auto const amount = value.TryGetNumeric().value();
amount.unscaledValue; // 92233720368547 — exact
amount.scale; // 4
amount.ToString(); // "9223372036.8547"
amount.ToDouble(); // approximation; lossy beyond a double's 15–16 significant digitsTryGetLongLong() still works for a DECIMAL(p, 0) column, which has no fractional part.
Both directions use the column's decimal literal rather than SQL_C_NUMERIC. Retrieving
SQL_C_NUMERIC requires the application to publish precision and scale on the descriptor
before each call and a driver that honours them — SQL Server returns scale-0 values
otherwise, and SQLite has no native SQL_NUMERIC_STRUCT support at all. These are the same
two backends SqlDataBinder<SqlNumeric<P, S>> routes around via
NativeNumericSupportIsBroken(). Every supported driver converts a decimal column to and
from its literal exactly, so the literal is the portable representation.
This is why SqlNumeric<P, S> is not simply reused here. On those same two backends its own
binder falls back to SQL_C_DOUBLE, so it is the exact type only where the driver's
SQL_NUMERIC_STRUCT support is sound — precisely the backends LASTRADA does not run on.
SqlDynamicNumeric also carries precision and scale as runtime values, which a variant
filled from a column described only at runtime needs.
SqlDynamicNumeric holds the unscaled value in an int64_t, so it represents at most
SqlMaxDynamicNumericPrecision (19) significant digits. A wider column — DECIMAL(38, 0) is
legal ODBC — cannot be read exactly, and the binder refuses rather than returning a rounded
number. The driver does not fail in that case, so no ODBC diagnostic exists to report:
| Entry point | On an unrepresentable value |
|---|---|
GetColumn |
Throws SqlException with a synthesized SQLSTATE 22003 and a message naming the limit. Reading the statement's last error instead would yield an empty — or stale — diagnostic. Consequently it is not noexcept. |
TryGetColumn |
Returns SQL_ERROR and posts nothing. For callers that can degrade. |
SqlVariant |
Falls back to double, so such a column stays readable (approximately) exactly as it was before this arm became exact. |
Retrieving the column is split from interpreting it — ReadLiteral and FromColumnLiteral — because
ODBC does not let you read the same column twice. A second SQLGetData is permitted only while a
character or binary value is still being delivered in parts; once it has arrived in full, a conforming
driver answers SQL_NO_DATA, and SQL Server's and PostgreSQL's both do. So SqlVariant derives the
exact value and its double fallback from one retrieved literal rather than re-reading the column.
Because GetColumn can now throw, the generic forwarders that wrap an arbitrary binder —
SqlDataBinder<std::optional<T>>, Field<T>, BelongsTo<T> — are no longer noexcept either. They
never could honour it: SqlDataBinder<SqlNumeric<P, S>>::GetColumn has always thrown from
RequireSuccess on a failed descriptor call, so a std::optional<SqlNumeric<19, 4>> column was a
latent std::terminate.
Binary columns keep their own SqlBinary alternative rather than collapsing into
std::string: InputParameter dispatches on the alternative, so a value read from a binary
column and written straight back must stay distinguishable from text to bind as
SQL_C_BINARY instead of SQL_C_CHAR. TryGetBinary() hands back a copy; TryGetStringView()
is the zero-copy view over the same bytes.
When using SqlVariant (or any binder that dispatches on the driver-reported
column type) against PostgreSQL via psqlODBC, the following options must appear
in the connection string:
| Option | Required value | What it controls |
|---|---|---|
BoolsAsChar |
0 |
Reports BOOLEAN columns as SQL_BIT so they dispatch into the bool alternative. The driver default reports them as SQL_CHAR. |
LFConversion |
0 |
Disables LF↔CRLF translation. Required for byte-exact round-trips of TEXT/VARCHAR values containing \n. |
The canonical postgres entry with these options is in scripts/tests/.test-env.yml.