Describe the feature
Add a shared, driver-agnostic connection pool to vlib/db/ that sits on top of the existing db.Driver interface. Go's database/sql design separates concerns as:
sql.DB (pooled handle) → driver.Conn (single raw connection)V currently only has the second half (db.Driver / db.open() → single connection), with no shared pooling layer. Each backend ends up reinventing its own pool with wildly different quality:
- pg has a mature pool (mutex, LIFO idle stack, waiters, lifetime mgmt, stats)
- mysql/sqlite have trivial
chan-based pools (fixed size, no health checks, no lifetime)
The pool logic from vlib/db/pg/pool.v should be lifted into vlib/db/ so all backends benefit equally.
Use Case
Writing a production MySQL service using db.open() — the &Driver returned is a single connection, not safe for concurrent use. To get pooling I have to drop down to mysql.ConnectionPool, which is a fixed-size channel that pre-opens every connection at construction, has no way to expire/replace stale connections, no health checking, and no dynamic reconfiguration. The only proper pool lives in the pg module and isn't accessible from MySQL.
Same problem if I'm a third-party driver author: there's no shared pool to reuse, I have to write my own from scratch.
Comparison with Go's database/sql
This section maps each major concept in Go's database/sql to V's current state and the proposed target, to give the refactoring a clear direction.
Layer architecture
| Layer | Go's design | V currently | V proposed |
|---|---|---|---|
| Pooled handle | sql.DB — thread-safe, lazy conns, configurable | ❌ no shared pooled type. pg.DB (own impl) is per-backend only | db.DB — shared, any backend, same config surface |
| Raw single conn | driver.Conn — backend returns one | ✅ db.Driver interface + db.open() → &Driver | ✅ same, unchanged |
| Conn factory | driver.Connector — creates one Conn | ❌ each backend's connect() is called directly inside open() match | db.DriverFactory — backend implements; pool holds it internally, calls connect() lazily |
| Connection pool | built into sql.DB, all drivers share it | ❌ 3 different pool impls (pg=good, mysql=bad, sqlite=bad) | db.Pool — once, shared, used by all backends |
| Driver registration | sql.Register() — runtime, any import registers itself | ❌ compile-time -d db_mysql / -d db_pg flags, not extensible by third-party | remain compile-time for bundled backends; add db.register() for third-party |
Pool semantics
| Concept | Go's approach | V proposed |
|---|---|---|
| User-visible acquire/release | mostly hidden inside sql.DB. Only db.Conn(ctx) + .Close() for pinning | pool.acquire() / pool.release() — explicit, needed by ORM's sql db { ... } blocks. release is clearer than Go's overloaded .Close() |
| Release cleanup | if driver implements driver.SessionResetter, calls ResetSession(ctx) before returning to idle pool | pool.release() internally calls driver.reset(). If reset fails, conn is closed and discarded — same semantics |
| Close pool | sql.DB.Close() | pool.close() — same |
API surface comparison
| Operation | Go sql.DB | V current | V proposed |
|---|---|---|---|
| Open | sql.Open(driver, dsn) | db.open(DriverConfig) → &Driver | db.open() stays for backward compat; db.open_pooled() → &db.DB. Future: db.open() defaults to pooled, db.open_direct() for bare &Driver |
| Exec (no rows) | db.ExecContext(ctx, query, args...) | driver.exec(query) | db.exec(query) — acquire/release wrapped |
| Query (rows) | db.QueryContext(ctx, query, args...) → *Rows | driver.exec(query) returns []DriverRow | db.query() → &Rows (Phase 6, see Phased approach) |
| Single row | db.QueryRowContext(ctx, query, args...) → Row.Scan(...) | driver.exec_one(query) → DriverRow | db.exec_one() — same signature |
| Ping | db.PingContext(ctx) | driver.validate() | db.ping() |
| Transaction | db.BeginTx(ctx, opts) → *Tx | pg has DB.begin() → &Tx; mysql has db.begin() but no Tx type | db.begin() → &Tx — shared for all backends |
| Configure pool | SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime | pg has set_max_open_conns() etc; mysql/sqlite have nothing | db.set_max_open_conns() etc — shared for all backends |
| Stats | db.Stats() → DBStats | pg.DB.stats() → PoolStats; others have nothing | db.stats() → PoolStats — shared |
| Close | db.Close() | driver.close() | db.close() — closes pool |
Transaction handling
| Aspect | Go | V current | V proposed |
|---|---|---|---|
| Tx type | sql.Tx — shared, pinned conn | pg has Tx; mysql has no Tx type | shared db.Tx for all backends |
| Tx lifecycle | tx.Commit() / tx.Rollback() releases the pinned conn back | pg Tx.commit() / Tx.rollback() → finish() → conn.close() → pool release | same, but shared |
| Tx safety | double-commit/rollback returns error | pg Tx has done flag to prevent double-finish | same pattern |
| Nested/savepoints | no built-in support (workaround via SAVEPOINT) | pg Tx has savepoint() / rollback_to() / release_savepoint() | share these for all backends |
Prepared statements
| Aspect | Go | V current | V proposed (Phase 5) |
|---|---|---|---|
| Stmt type | sql.Stmt — safe across goroutines | StmtHandle (mysql) / Conn.prepare() (pg) — session-scoped only | shared db.Stmt with lazy-prepare per conn |
| Pooled Prepare | db.Prepare() returns *Stmt that lazily prepares on each conn on first use | pg DB.prepare() prepares on one random conn — useless for others. MySQL has no pooled prepare at all | db.prepare() creates a Stmt that tracks per-conn preparation |
| Per-conn Prepare | conn.PrepareContext() for session-scoped prepared stmts | pg Conn.prepare(), mysql DB.prepare() (single-conn) | keep as-is for session-scoped usage |
Result streaming and scanning
| Aspect | Go | V current | V proposed (Phase 6) |
|---|---|---|---|
| Rows type | *sql.Rows — iterator, fetches lazily | []DriverRow — whole set in memory | &db.Rows — iterator, next() / scan() API |
| Column scanning | rows.Scan(&dest1, &dest2, ...) — typed, no string round-trip | DriverRow.vals []string — everything is string, ORM re-parses | rows.scan(&dest1, &dest2) — typed, type-safe |
| Row lifetime | must rows.Close() when done, or auto-closes on Next() returning false | no close needed (array is fully materialized) | rows.close() or defer |
Context support
| Aspect | Go | V current | V proposed (Phase 7) |
|---|---|---|---|
| Cancellation | every operation takes context.Context — cancel kills query on server | ❌ no cancellation mechanism | db.exec(ctx, query) — requires V to have a context mechanism first; not something db module can solve alone |
| Deadline | context.WithTimeout → auto-cancel after deadline | ❌ no timeout support | same dependency on V-level context |
| Connection closing | conn.Close() interrupts blocked queries | ❌ no interrupt mechanism | future |
Driver registration
| Aspect | Go | V current | V proposed (future) |
|---|---|---|---|
| Registration model | runtime — sql.Register("name", driver) in init(), then sql.Open("name", dsn) | compile-time — -d db_mysql / -d db_pg flags, hard-coded match in driver.v | keep compile-time for bundled backends; add db.register(kind, factory) for third-party |
| Third-party driver | import + blank-identifier, auto-registers | ❌ must fork V or use per-backend types directly | third-party implements DriverFactory, calls db.register(.custom, factory) |
Proposed Solution
Layer on top of the existing db.Driver
The db.Driver interface already exists and works. The missing piece is a pooling wrapper — a db.DB type that holds a pool of connections and transparently acquires/releases them per call.
The pool returns a lightweight wrapper (Conn) rather than a bare &Driver, so that stale references held by user code after release() can't accidentally reach the underlying driver handle. The Conn also provides delegate methods (with nil-guards) so callers don't need to touch the raw &Driver:
// vlib/db/pool.v — extracted from vlib/db/pg/pool.v, made driver-agnostic
// Conn is a pooled connection handle. After release(), the inner driver
// pointer is detached — any subsequent method call on the same &Conn
// returns an error instead of corrupting the pool.
@[heap]
pub struct Conn {
mut:
driver &Driver = unsafe { nil }
pool &Pool = unsafe { nil }
created_at time.Time
bad bool
}
// ensure_active prevents use of a released connection.
fn (c &Conn) ensure_active() ! {
if isnil(c.driver) {
return error('db: connection is released')
}
}
// Delegate methods. Each checks ensure_active first, then forwards to c.driver.
// This mirrors pg's Conn.ensure_active() pattern and makes the nil-guard automatic.
pub fn (mut c Conn) exec(query string) ![]DriverRow {
c.ensure_active()!
return c.driver.exec(query)
}
pub fn (mut c Conn) exec_one(query string) !DriverRow {
c.ensure_active()!
return c.driver.exec_one(query)
}
pub fn (mut c Conn) exec_param_many(query string, params []string) ![]DriverRow {
c.ensure_active()!
return c.driver.exec_param_many(query, params)
}
pub fn (mut c Conn) validate() !bool {
c.ensure_active()!
return c.driver.validate()
}
pub fn (mut c Conn) close() ! {
if isnil(c.pool) {
unsafe { c.driver.close() } // standalone conn, no pool
return
}
c.pool.release(c)
}// Pool manages a set of idle connections and hands them out on acquire.
// It never exposes Driver directly — callers get &Conn wrappers.
pub struct Pool {
factory DriverFactory
// ... idle slots, max_open, max_idle, max_lifetime, waiters, etc
}
pub fn new_pool(factory DriverFactory, cfg PoolConfig) &Pool { ... }
// acquire returns a &Conn. It can fail when:
// - the pool is closed
// - DriverFactory.connect() fails (e.g. network error, auth failure)
// - the waiter channel is closed (pool closed while waiting)
pub fn (mut p Pool) acquire() !&Conn { ... }
pub fn (mut p Pool) release(conn &Conn) { ... }
pub fn (mut p Pool) close() { ... }
pub fn (mut p Pool) set_max_open(n int) { ... }
pub fn (mut p Pool) set_max_idle(n int) { ... }
pub fn (mut p Pool) set_max_lifetime(d time.Duration) { ... }
pub fn (mut p Pool) stats() PoolStats { ... }
// PoolConfig — shared config for all backends
pub struct PoolConfig {
max_open_conns int // 0 = unlimited
max_idle_conns int = 2 // 0 = keep no idle conns
conn_max_lifetime time.Duration // 0 = unlimited
}
pub struct PoolStats {
max_open_connections int
open_connections int
in_use int
idle int
wait_count int
}// vlib/db/db.v — new pooled DB handle
// DB is a thread-safe, pooled handle to any database.
// It only holds a reference to a Pool — the factory is inside the pool.
@[heap]
pub struct DB {
mut:
pool &Pool = unsafe { nil }
}
// Methods acquire a Conn, call its delegate, then release:
pub fn (mut db DB) exec(query string) ![]DriverRow {
mut c := db.pool.acquire()!
defer { db.pool.release(c) }
return c.exec(query)
}
pub fn (mut db DB) exec_one(query string) !DriverRow {...}
pub fn (mut db DB) exec_param_many(query string, params []string) ![]DriverRow {...}
pub fn (mut db DB) begin() !&Tx {...}
pub fn (mut db DB) close() ! { db.pool.close() }
pub fn (mut db DB) ping() ! {...}
// Future (Phase 6): streamed rows
// pub fn (mut db DB) query(query string) !&Rows { ... }
// Pool configuration — delegates to pool
pub fn (mut db DB) set_max_open_conns(n int) { db.pool.set_max_open(n) }
pub fn (mut db DB) set_max_idle_conns(n int) { db.pool.set_max_idle(n) }
pub fn (mut db DB) set_conn_max_lifetime(d time.Duration) { db.pool.set_max_lifetime(d) }
pub fn (mut db DB) stats() PoolStats { return db.pool.stats() }Backend example: MySQL implements DriverFactory
// vlib/db/mysql/factory.c.v — new file, or inline in driver.v's $if db_mysql block
pub struct MysqlFactory {
config mysql.Config
}
pub fn (f MysqlFactory) connect() !&Driver {
conn := mysql.connect(f.config)!
return &MysqlDriver{ conn: conn }
}Then db.open_pooled() creates the pool with the right factory:
fn open_mysql_pooled(config DriverConfig) !&DB {
mysql_config := mysql.Config{ ... }
factory := MysqlFactory{ config: mysql_config }
pool := new_pool(factory, PoolConfig{ ... })
return &DB{ pool: pool }
}Note: DriverFactory only requires connect() for now. If a factory holds resources (e.g. cached TLS config), a future close() method can be added to the interface — backward compatibly, since factory is always held by the pool.
Pool API naming
The shared pool exposes three public methods — acquire, release, close:
// acquire 从池中取一条连接。由 DriverFactory.connect() 延迟创建。
pub fn (mut p Pool) acquire() !&Conn { ... }
// release 归还连接。内部断开 &Conn.driver 防止二次使用,
// 然后自动调 driver.reset() 清理 session 状态。
// reset 成功则放回空闲池;reset 失败则关闭丢弃。
pub fn (mut p Pool) release(conn &Conn) { ... }
// close 关闭池中所有空闲连接。已借出的连接归还时直接关闭。
pub fn (mut p Pool) close() { ... }The relationship with Go's naming:
- Go's pool is almost invisible to the user —
sql.DB.Exec/Query/Beginhandle acquire/release internally. Onlydb.Conn(ctx)+.Close()surfaces it for pinning - V's ORM has
sql db { ... }blocks that need explicit connection pinning, soacquire/releaseas public methods make sense releaseis clearer than Go's.Close():.Close()is ambiguous (close the connection or return to pool?), whilereleaseexplicitly means "give it back"closeshuts down the entire pool — same as Go
Release lifecycle (reset + return)
Current pg pool in release() only checks bad/expired and either discards or parks the idle slot — it never calls reset(). With the shared pool, the release flow becomes:
release(conn):
driver = conn.driver
conn.driver = nil // detach — stale refs now inert
conn.pool = nil
driver.reset() or {
driver.close() // reset 失败 → 关闭丢弃
open_count--
return
}
park_as_idle(driver)This mirrors how Go's driver.SessionResetter.ResetSession(ctx) works: the driver gets a chance to clean up session-scoped state before the connection goes back to the idle pool. Each backend implements reset() appropriately: mysql_reset_connection() for MySQL, DISCARD ALL for PostgreSQL, no-op for SQLite.
The detach-first design means that even if user code holds a stale &Conn after release(), any subsequent method call on it will hit ensure_active() → conn.driver == nil → error, never reaching the driver handle that may now be serving another goroutine.
The pool itself lives once, in vlib/db/pool.v
The pg pool is already well-designed (~350 lines). Extracting it requires minimal changes:
| pg pool concept | Shared pool equivalent |
|---|---|
pg.connect_slot(conninfo) | factory.connect() returns &Driver |
IdleSlot { handle, created_at } | Conn (holds &Driver + timestamp) |
physical_close_handle(handle) | driver.close() |
slot_expired() / slot_bad() | driver.validate() / timestamp check |
db.open() stays unchanged, migration path
Existing code using db.open() → &Driver continues to work. New code opts into pooling via db.open_pooled():
| Stage | Entry point | Returns |
|---|---|---|
| Now (existing) | db.open(config) | &Driver — single connection, no pooling |
| Phase 1 (new) | db.open_pooled(config) | &DB — pooled, thread-safe |
| Future direction | db.open(config) → defaults to pooled | &DB — breaking change, but for V 2.0 or after deprecation period |
| Future direction | db.open_direct(config) → for bare conn | &Driver — explicit escape hatch |
This mirrors Go's sql.Open (pooled by default) vs driver.Open/sql.Conn (raw).
ORM integration
The ORM's Connection interface currently requires select/insert/update/delete/last_id/execute directly on each backend. A pooled DB implementing Connection would let the ORM work transparently with any backend through one type.
Current state at a glance
| Feature | Go sql.DB | V pg module | V mysql module | db.open() → &Driver |
|---|---|---|---|---|
Has a shared DB type | ✅ sql.DB | ✅ pg.DB (per-backend) | ❌ (no pooled handle) | ❌ single conn |
| max_open_conns | ✅ | ✅ | ❌ fixed | N/A |
| max_idle_conns | ✅ | ✅ | ❌ | N/A |
| conn_max_lifetime | ✅ | ✅ | ❌ | N/A |
| lazy connection open | ✅ | ✅ | ❌ pre-opens all | N/A |
| health checks on acquire | ✅ | ✅ (PQstatus) | ❌ | N/A |
| waiter queue (cap hit) | ✅ | ✅ chan signaling | ❌ chan blocks | N/A |
| dynamic reconfiguration | ✅ | ✅ | ❌ | N/A |
| stats | ✅ | ✅ | ❌ | N/A |
| bad conn detection | ✅ | ✅ | ❌ | N/A |
| background lifetime reaper | ✅ | ❌ on acquire only | ❌ | N/A |
Other Information
Backward compatibility
db.open()continues to return&Driver— no breakagedb.open_pooled()is the new entry point returning&DB- The existing
pg.DB,pg.Pool,mysql.ConnectionPool,sqlite.ConnectionPoolremain untouched initially - ORM backends (
pg.orm.v,mysql.orm.c.v,sqlite.orm.v) keep their current impls; a future step can add a generic ORM layer on top ofdb.DB
Key files
vlib/db/driver.v— existingDriverinterface +open(); addDriverFactory+open_pooled()herevlib/db/pg/pool.v— mature pool to extract asvlib/db/pool.vvlib/db/pg/db.v— pg'sDBpool wrapper (reference for the shared one)vlib/db/pg/tx.v— pg'sTx(reference for sharedTx)vlib/db/mysql/pool.v— primitive channel pool (to be removed)vlib/db/sqlite/pool.v— samevlib/orm/orm.v—Connection/TransactionalConnectioninterfaces
Phased approach
| Phase | Scope | Changes |
|---|---|---|
| 1 | Extract pool + DriverFactory + DB | Move Pool struct + logic from vlib/db/pg/pool.v to vlib/db/pool.v; add DriverFactory interface, Conn wrapper, DB handle, open_pooled() in driver.v. These are inseparable — you need the pool to have a DB, and you need DriverFactory for the pool to create connections |
| 2 | Shared Tx | Extract Tx from pg to vlib/db/tx.v |
| 3 | Deprecate old pools | Mark pg.Pool, mysql.ConnectionPool, sqlite.ConnectionPool as [deprecated] |
| 4 | Remove old pools | After the deprecation period (typically 2 releases), delete per-backend pool types. After this, pg/mysql/sqlite no longer ship their own pool — everyone uses db.Pool |
| 5 | Prepared statement manager | Lazy-prepare per conn (like Go's sql.Stmt) |
| 6 | Streamed Rows / Scan | Iterator-style result sets + typed column mapping |
| 7 | Context / timeout | Query cancellation and deadline propagation. Prerequisite: V needs a general context/cancellation mechanism first — this is not something the db module can solve alone |
Future direction
The end state is one shared db.Pool + db.DB that all backends use, backed by a per-backend DriverFactory. After a deprecation period of at least 2 releases, pg.Pool, mysql.ConnectionPool, and sqlite.ConnectionPool should be removed — they become dead code. Third-party driver authors only implement DriverFactory and get the full pooling stack for free.
Longer term, the entry point should flip: db.open() returns a pooled &DB by default, and users who need a bare single connection call db.open_direct() → &Driver.
Further down the road:
- Prepared statement manager: a
Stmttype prepared lazily per connection (like Go'ssql.Stmt), soDB.Prepare()actually works across pooled connections instead of being a session-scoped no-op - Streamed
Rows: iterator that fetches rows on demand instead of materialising the entire result set as[]Row - Typed
Scan: map columns directly to typed V variables instead of round-tripping through[]string - Context / timeout: propagate cancellation and deadlines through all pool operations. This is blocked on V having a general-purpose context/cancellation mechanism — not actionable within the db module alone
NOTE
You can use the 👍 reaction to increase the issue's priority for developers.
Please note that only the 👍 reaction to the issue itself counts as a vote. Other reactions and those to comments will not be taken into account.
Activity
13I had to write my own connection pool from scratch for firebirdsql and wish I could have just implemented a standard interface instead.
Looking at the Go comparison, there is a key difference: Go's standard library doesn't actually include database drivers. Third-party drivers live outside it and often use their own APIs. Right now, all database drivers in vlib are just C wrappers. My Firebird library is pure V and isn't part of vlib.
I suggest (may seem drastic) removing all database connectors from the standard library entirely and moving them to independent packages (like a "v endorsed" collection). Shifting vlib/db to just provide pooling interfaces would help encourage this kind of userspace development. Because there currently are no database clients written in V (except for mine), this feature request gives userspace a direction for their development.
I had to write my own connection pool from scratch for firebirdsql and wish I could have just implemented a standard interface instead.
Looking at the Go comparison, there is a key difference: Go's standard library doesn't actually include database drivers. Third-party drivers live outside it and often use their own APIs. Right now, all database drivers in vlib are just C wrappers. My Firebird library is pure V and isn't part of vlib.
I suggest (may seem drastic) removing all database connectors from the standard library entirely and moving them to independent packages (like a "v endorsed" collection). Shifting vlib/db to just provide pooling interfaces would help encourage this kind of userspace development. Because there currently are no database clients written in V (except for mine), this feature request gives userspace a direction for their development.
I had to write my own connection pool from scratch for firebirdsql and wish I could have just implemented a standard interface instead.
Looking at the Go comparison, there is a key difference: Go's standard library doesn't actually include database drivers. Third-party drivers live outside it and often use their own APIs. Right now, all database drivers in vlib are just C wrappers. My Firebird library is pure V and isn't part of vlib.
I suggest (may seem drastic) removing all database connectors from the standard library entirely and moving them to independent packages (like a "v endorsed" collection). Shifting vlib/db to just provide pooling interfaces would help encourage this kind of userspace development. Because there currently are no database clients written in V (except for mine), this feature request gives userspace a direction for their development.
I had to write my own connection pool from scratch for firebirdsql and wish I could have just implemented a standard interface instead.
Looking at the Go comparison, there is a key difference: Go's standard library doesn't actually include database drivers. Third-party drivers live outside it and often use their own APIs. Right now, all database drivers in vlib are just C wrappers. My Firebird library is pure V and isn't part of vlib.
I suggest (may seem drastic) removing all database connectors from the standard library entirely and moving them to independent packages (like a "v endorsed" collection). Shifting vlib/db to just provide pooling interfaces would help encourage this kind of userspace development. Because there currently are no database clients written in V (except for mine), this feature request gives userspace a direction for their development.
Waiting for your improvement
Waiting for your improvement
Waiting for your improvement
Waiting for your improvement
I'm sorry I am not going to provide any improvement, I just expressed my thoughts and opinions.
There is something I personally don't like about the go database/sql interface: it assumes that the dbms stream the result set to the client, but firebirdsql does not stream the response.
I feel (I have no data) that postgresql is the most popular sql dbms right now, and in golang the driver pgx provides its own abstractions and pooling, because the database/sql interface is too naive to cover all the features postgresql offers.
While V still does not even have a postgresql native driver, I think there is a parallel with the attempt to fit a Redis driver in the db pooling abstraction in V.
Waiting for your improvement
I'm sorry I am not going to provide any improvement, I just expressed my thoughts and opinions.
There is something I personally don't like about the go database/sql interface: it assumes that the dbms stream the result set to the client, but firebirdsql does not stream the response.
I feel (I have no data) that postgresql is the most popular sql dbms right now, and in golang the driver pgx provides its own abstractions and pooling, because the database/sql interface is too naive to cover all the features postgresql offers.
While V still does not even have a postgresql native driver, I think there is a parallel with the attempt to fit a Redis driver in the db pooling abstraction in V.
@einar-hjortdal is Go pgx faster than pg's native official C driver?
Go needs native drivers, V can just use official C drivers, so why re-write them in V?
Your FirebirdSQL driver looks great, we can make it official and installable via v install firebirdsql
@einar-hjortdal is Go pgx faster than pg's native official C driver?
I have no idea if it is faster, but it has features that cannot be implemented on top of the database/sql interface, so it ships its own implementation of pooling etc. That's why I don't think v has to have the same kind of abstraction
Go needs native drivers, V can just use official C drivers, so why re-write them in V?
On one hand I agree it is a lot of work that isn't needed for the majority of cases, but a native V implementation would be more portable
I had to write my own connection pool from scratch for firebirdsql and wish I could have just implemented a standard interface instead.
Looking at the Go comparison, there is a key difference: Go's standard library doesn't actually include database drivers. Third-party drivers live outside it and often use their own APIs. Right now, all database drivers in vlib are just C wrappers. My Firebird library is pure V and isn't part of vlib.
I suggest (may seem drastic) removing all database connectors from the standard library entirely and moving them to independent packages (like a "v endorsed" collection). Shifting
vlib/dbto just provide pooling interfaces would help encourage this kind of userspace development. Because there currently are no database clients written in V, this feature request gives a direction for further development.