SQL Types¶
SqlValue is the closed union of every value type the library carries across the ODBC boundary. Every parameter you bind is a SqlValue. Every column you fetch is a SqlValue (wrapped in a Row or MutableRow).
type SqlValue is
( SqlNull
| SqlBool
| SqlTinyInt | SqlSmallInt | SqlInteger | SqlBigInt
| SqlFloat
| SqlText
| SqlDate | SqlTime | SqlTimestamp
| SqlDecimal )
Closed means there’s no extension point — if the library doesn’t have a variant for a SQL type, you can’t add one without modifying the library. The trade-off: exhaustive matching and no hidden dispatch, at the cost of flexibility.
The variants¶
| Variant | Pony carrier | Typical SQL types |
|---|---|---|
SqlNull |
(primitive) | NULL in any column |
SqlBool |
Bool |
BOOLEAN, BIT |
SqlTinyInt |
I8 |
TINYINT (not on Postgres) |
SqlSmallInt |
I16 |
SMALLINT |
SqlInteger |
I32 |
INTEGER, INT |
SqlBigInt |
I64 |
BIGINT |
SqlFloat |
F64 |
REAL, FLOAT, DOUBLE PRECISION |
SqlText |
String val |
CHAR, VARCHAR, TEXT, LONGVARCHAR |
SqlDate |
(year, month, day) |
DATE |
SqlTime |
(hour, minute, second) |
TIME |
SqlTimestamp |
(date, time, fraction) |
TIMESTAMP, TIMESTAMPTZ |
SqlDecimal |
String val |
NUMERIC, DECIMAL |
A few variants deserve attention.
SqlFloat absorbs all floats¶
REAL, FLOAT, and DOUBLE PRECISION all come back through SQL_C_DOUBLE as SqlFloat (F64). There’s no separate SqlReal. Precision differences on the write side are your problem to enforce at the SQL level.
SqlText is UTF-8 validated by default¶
Odbc.connect(dsn, validate_utf8 = true) — the default — checks every text column against UTF-8 rules on fetch. Invalid bytes raise FetchError(InvalidUtf8). Pass false only when you trust the source (or want dirty bytes anyway).
SqlDecimal is a String¶
Exact numerics (NUMERIC(20, 4) etc.) don’t fit cleanly into F64, and silent conversion loses precision. SqlDecimal stores the driver’s text representation verbatim. Round-tripping is lossless; arithmetic is your problem.
SqlTimestamp has a nanosecond fraction¶
class val SqlTimestamp
let year: I16
let month: U16
let day: U16
let hour: U16
let minute: U16
let second: U16
let fraction: U32 // nanoseconds
Not all drivers populate fraction. Postgres sends microsecond precision, surfaced as nanoseconds with three trailing zeros.
Timezones aren’t represented directly. TIMESTAMPTZ comes back converted to the session’s time zone; select the offset separately or set the session zone explicitly if you need it.
The four integer types¶
Widths are preserved on both sides. SqlTinyInt(3) binds as SQL_TINYINT; SqlBigInt(3) binds as SQL_BIGINT. Some databases (Postgres) lack narrower column types — drivers widen for you on write.
Reads preserve width too: a SMALLINT column comes back as SqlSmallInt, not SqlBigInt. Row.int() exists so you don’t have to match on the width when you don’t care.
Round-tripping every variant¶
The sample creates a table with one column per supported variant, binds a value into each, and reads them back:
use "lib:odbc"
use "odbc"
actor Main
new create(env: Env) =>
let dsn_name =
try env.args(1)?
else "psqlred"
end
match Odbc.connect(Dsn("DSN=" + dsn_name))
| let conn: Connection =>
conn.exec("DROP TABLE IF EXISTS tut_types")
match \exhaustive\ conn.exec(
"CREATE TABLE tut_types ("
+ "flag BOOLEAN, "
+ "big BIGINT, "
+ "ratio DOUBLE PRECISION, "
+ "label TEXT, "
+ "born DATE, "
+ "at TIME, "
+ "ts TIMESTAMP, "
+ "amount NUMERIC(20, 4))")
| let _: (USize | NoRowCount) => None
| let e: ExecError =>
env.err.print("create: " + e.string()); conn.close(); return
end
match \exhaustive\ conn.prepare(
"INSERT INTO tut_types VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
| let stmt: Statement =>
try
stmt.bind_p(ParamIndex(1), SqlBool(true))?
stmt.bind_p(ParamIndex(2), SqlBigInt(9_000_000_000))?
stmt.bind_p(ParamIndex(3), SqlFloat(3.14159))?
stmt.bind_p(ParamIndex(4), SqlText("hello"))?
stmt.bind_p(ParamIndex(5), SqlDate(2026, 4, 16))?
stmt.bind_p(ParamIndex(6), SqlTime(9, 30, 0))?
stmt.bind_p(ParamIndex(7),
SqlTimestamp(2026, 4, 16, 9, 30, 0))?
stmt.bind_p(ParamIndex(8), SqlDecimal("1234.5678"))?
stmt.execute_update_p()?
else
env.err.print("bind/execute failed")
end
stmt.close()
| let e: PrepareError =>
env.err.print("prepare: " + e.string())
end
match \exhaustive\ conn.query(
"SELECT flag, big, ratio, label, born, at, ts, amount "
+ "FROM tut_types")
| let cursor: Cursor =>
match cursor.fetch()
| let row: Row =>
try
let flag =
match \exhaustive\ row.bool(ColIndex(1))?
| let v: Bool => v.string()
| SqlNull => "(null)"
end
let big =
match \exhaustive\ row.int(ColIndex(2))?
| let v: I64 => v.string()
| SqlNull => "(null)"
end
let ratio =
match \exhaustive\ row.float(ColIndex(3))?
| let v: F64 => v.string()
| SqlNull => "(null)"
end
let label =
match \exhaustive\ row.text(ColIndex(4))?
| let v: String val => v
| SqlNull => "(null)"
end
let born =
match \exhaustive\ row.date(ColIndex(5))?
| let v: SqlDate => v.string()
| SqlNull => "(null)"
end
let at =
match \exhaustive\ row.time(ColIndex(6))?
| let v: SqlTime => v.string()
| SqlNull => "(null)"
end
let ts =
match \exhaustive\ row.timestamp(ColIndex(7))?
| let v: SqlTimestamp => v.string()
| SqlNull => "(null)"
end
let amount =
match \exhaustive\ row.decimal(ColIndex(8))?
| let v: SqlDecimal => v.string()
| SqlNull => "(null)"
end
env.out.print("flag: " + flag)
env.out.print("big: " + big)
env.out.print("ratio: " + ratio)
env.out.print("label: " + label)
env.out.print("born: " + born)
env.out.print("at: " + at)
env.out.print("ts: " + ts)
env.out.print("amount: " + amount)
else
env.err.print(" read error")
end
end
cursor.close()
| let e: ExecError =>
env.err.print("query: " + e.string())
end
conn.exec("DROP TABLE IF EXISTS tut_types")
conn.close()
| let e: ConnectError =>
env.err.print("connect: " + e.string())
end
./build/08-all-types
flag: true
big: 9000000000
ratio: 3.14159
label: hello
born: 2026-04-16
at: 09:30:00
ts: 2026-04-16 09:30:00
amount: 1234.5678
!!! note “Missing variants”
Postgres lacks distinct column types for SqlTinyInt, SqlSmallInt, and
SqlInteger in this sample — we’d just be watching the driver widen them
to BIGINT. On MariaDB, pick TINYINT / SMALLINT / INT columns to see
the narrower variants come back.
What’s next¶
So far every statement has been a literal string. Prepared Statements fixes that.