From f581cbb1c964f89133439f4e303136998ddcfcfb Mon Sep 17 00:00:00 2001 From: Jengro Date: Sat, 27 Jun 2026 05:30:11 +0800 Subject: [PATCH] orm: add execute method to Connection interface for raw SQL queries (#27416) --- vlib/db/mysql/orm.c.v | 30 ++++++++ vlib/db/pg/orm.v | 32 ++++++++ vlib/db/pg/pg.c.v | 17 ++-- vlib/db/pg/pg_result_test.v | 27 +++++++ vlib/db/pg_sqlite_consistency_test.v | 77 +++++++++++++++++++ vlib/db/sqlite/orm.v | 6 ++ vlib/orm/README.md | 19 +++++ vlib/orm/orm.v | 8 ++ vlib/orm/orm_mut_connection_test.v | 14 ++++ vlib/orm/orm_null_test.v | 4 + vlib/orm/orm_scope.v | 5 ++ vlib/orm/orm_scope_test.v | 15 ++++ vlib/orm/orm_transaction_test.v | 31 ++++++++ vlib/orm/transaction.v | 6 ++ .../tests/orm_db_expr_option_error.out | 14 ++++ 15 files changed, 298 insertions(+), 7 deletions(-) diff --git a/vlib/db/mysql/orm.c.v b/vlib/db/mysql/orm.c.v index ba894ecfb..ce81befa5 100644 --- a/vlib/db/mysql/orm.c.v +++ b/vlib/db/mysql/orm.c.v @@ -195,6 +195,36 @@ pub fn (db DB) drop(table orm.Table) ! { mysql_stmt_worker(db, query, orm.QueryData{}, orm.QueryData{})! } +// execute runs a raw SQL query and returns result rows as driver-agnostic orm.Row values, +// with column names populated from the result metadata. +pub fn (db DB) execute(query string) ![]orm.Row { + mut guard := db.acquire_connection_guard()! + defer { + guard.release() + } + if C.mysql_query(guard.conn, query.str) != 0 { + throw_mysql_error_for_conn(guard.conn)! + } + + result := C.mysql_store_result(guard.conn) + if result == unsafe { nil } { + if get_errno(guard.conn) != 0 { + throw_mysql_error_for_conn(guard.conn)! + } + return []orm.Row{} + } else { + res := Result{result} + defer { unsafe { res.free() } } + fields := res.fields() + mut names := []string{} + for f in fields { + names << f.name + } + rows := res.rows() + return rows.map(orm.Row{ vals: it.vals, names: names }) + } +} + // orm_begin starts a transaction for ORM helpers. pub fn (mut db DB) orm_begin() ! { db.begin()! diff --git a/vlib/db/pg/orm.v b/vlib/db/pg/orm.v index 5459b9314..3a9a0d448 100644 --- a/vlib/db/pg/orm.v +++ b/vlib/db/pg/orm.v @@ -80,6 +80,25 @@ pub fn (c &Conn) drop(table orm.Table) ! { c.exec(query)! } +// execute runs a raw SQL query and returns result rows as driver-agnostic orm.Row values, +// with column names populated from the result metadata. +pub fn (c &Conn) execute(query string) ![]orm.Row { + res := c.exec_result(query)! + + mut orm_rows := []orm.Row{} + for r in res.rows { + mut vals := []string{} + for i in 0 .. r.vals.len { + vals << r.val(i) + } + orm_rows << orm.Row{ + vals: vals + names: res.names + } + } + return orm_rows +} + // orm_begin starts a transaction on this conn. pub fn (c &Conn) orm_begin() ! { c.begin_on_conn()! @@ -171,6 +190,13 @@ pub fn (mut db DB) drop(table orm.Table) ! { c.drop(table)! } +// execute runs a raw SQL query on a pooled conn and returns result rows. +pub fn (mut db DB) execute(query string) ![]orm.Row { + mut c := db.pool.acquire()! + defer { c.close() or {} } + return c.execute(query) +} + // last_id returns the id stashed by this thread's most recent `DB.insert` // (or 0 if there is none). LASTVAL() itself is session-scoped, so calling // it on a freshly-checked-out pool conn would return the wrong value or 0; @@ -218,6 +244,12 @@ pub fn (mut tx Tx) drop(table orm.Table) ! { tx.conn.drop(table)! } +// execute runs a raw SQL query on the pinned transaction conn and returns result rows. +pub fn (mut tx Tx) execute(query string) ![]orm.Row { + tx.ensure_active()! + return tx.conn.execute(query) +} + // last_id returns the last inserted id on the pinned conn. pub fn (mut tx Tx) last_id() int { if tx.done || isnil(tx.conn) { diff --git a/vlib/db/pg/pg.c.v b/vlib/db/pg/pg.c.v index d671ff704..af8de0084 100644 --- a/vlib/db/pg/pg.c.v +++ b/vlib/db/pg/pg.c.v @@ -112,8 +112,9 @@ pub mut: pub struct Result { pub: - cols map[string]int - rows []Row + cols map[string]int + names []string + rows []Row } // Notification represents a notification received from the server via LISTEN/NOTIFY @@ -443,14 +444,16 @@ fn res_to_result(res voidptr) Result { nr_cols := C.PQnfields(res) mut cols := map[string]int{} + mut names := []string{} + for j in 0 .. nr_cols { + field_name := unsafe { cstring_to_vstring(C.PQfname(res, j)) } + cols[field_name] = j + names << field_name + } mut rows := []Row{} for i in 0 .. nr_rows { mut row := Row{} for j in 0 .. nr_cols { - if i == 0 { - field_name := unsafe { cstring_to_vstring(C.PQfname(res, j)) } - cols[field_name] = j - } if C.PQgetisnull(res, i, j) != 0 { row.vals << none } else { @@ -462,7 +465,7 @@ fn res_to_result(res voidptr) Result { } C.PQclear(res) - return Result{cols, rows} + return Result{cols, names, rows} } // close releases this conn back to its pool. Safe to call more than once: diff --git a/vlib/db/pg/pg_result_test.v b/vlib/db/pg/pg_result_test.v index 0c2687e1d..f4ba21aa9 100644 --- a/vlib/db/pg/pg_result_test.v +++ b/vlib/db/pg/pg_result_test.v @@ -55,3 +55,30 @@ WHERE // println(infos) } + +fn test_empty_result_set_returns_col_names() { + $if !network ? { + eprintln('> Skipping test ${@FN}, since `-d network` is not passed.') + eprintln('> This test requires a working postgres server running on localhost.') + return + } + + mut db := pg.connect(pg.Config{ + user: 'postgres' + password: '12345678' + dbname: 'postgres' + })! + defer { + db.close() or {} + } + + // Query that returns column metadata but zero tuples + result := db.exec_result('SELECT 1 AS id WHERE false')! + + assert result.names.len == 1 + assert result.names[0] == 'id' + assert result.cols == { + 'id': 0 + } + assert result.rows.len == 0 +} diff --git a/vlib/db/pg_sqlite_consistency_test.v b/vlib/db/pg_sqlite_consistency_test.v index 4380adccb..dd5975b19 100644 --- a/vlib/db/pg_sqlite_consistency_test.v +++ b/vlib/db/pg_sqlite_consistency_test.v @@ -49,3 +49,80 @@ fn test_sqlite_row_value_helpers() { assert sqlite_row.val(0) == 'hello' assert sqlite_row.values() == ['hello', ''] } + +fn test_pg_result_empty_rows_synthetic() { + // Construct a Result directly with column names but no rows. + // This simulates what exec_result() should return for queries like + // "SELECT 1 AS id WHERE false" and verifies the downstream + // contract of Result with zero tuples. + res := pg.Result{ + cols: { + 'id': 0 + } + names: ['id'] + rows: [] + } + + assert res.names.len == 1 + assert res.names[0] == 'id' + assert res.cols == { + 'id': 0 + } + assert res.rows.len == 0 +} + +fn test_pg_result_multi_col_empty_rows() { + // Multiple columns, zero rows — the fix scenario with more than one column + res := pg.Result{ + cols: { + 'id': 0 + 'name': 1 + } + names: ['id', 'name'] + rows: [] + } + + assert res.names.len == 2 + assert res.names[0] == 'id' + assert res.names[1] == 'name' + assert res.cols == { + 'id': 0 + 'name': 1 + } + assert res.rows.len == 0 + + // Verify column lookup via cols map + assert res.cols['id'] == 0 + assert res.cols['name'] == 1 +} + +fn test_pg_result_no_cols_no_rows() { + // Zero columns, zero rows — edge case for DDL / command results + res := pg.Result{ + cols: map[string]int{} + names: []string{} + rows: [] + } + + assert res.names.len == 0 + assert res.cols.len == 0 + assert res.rows.len == 0 +} + +fn test_pg_result_as_structs_empty_rows() { + // as_structs must return an empty slice when there are no rows, + // even when column metadata is present. + res := pg.Result{ + cols: { + 'id': 0 + } + names: ['id'] + rows: [] + } + + structs := res.as_structs[map[string]string](fn (res pg.Result, row pg.Row) !map[string]string { + return map[string]string{} + })! + + assert structs.len == 0 +} diff --git a/vlib/db/sqlite/orm.v b/vlib/db/sqlite/orm.v index fe84fa051..02d610f8a 100644 --- a/vlib/db/sqlite/orm.v +++ b/vlib/db/sqlite/orm.v @@ -91,6 +91,12 @@ pub fn (db DB) drop(table orm.Table) ! { sqlite_stmt_worker(db, query, orm.QueryData{}, orm.QueryData{})! } +// execute runs a raw SQL query and returns result rows as driver-agnostic orm.Row values. +pub fn (db DB) execute(query string) ![]orm.Row { + rows := db.exec(query)! + return rows.map(orm.Row{ vals: it.vals, names: it.names }) +} + // orm_begin starts a transaction for ORM helpers. pub fn (mut db DB) orm_begin() ! { db.begin()! diff --git a/vlib/orm/README.md b/vlib/orm/README.md index a2ec0d809..a7110019c 100644 --- a/vlib/orm/README.md +++ b/vlib/orm/README.md @@ -133,6 +133,25 @@ return an error instead of being silently skipped. Call `db.unscoped()` to return a new `orm.DB` value that skips all scope filters. Call `db.unscoped('tenant_id')` to skip only selected fields. +### Raw SQL Execute + +Use `execute(query string) ![]orm.Row` when you need a driver-agnostic raw SQL +escape hatch outside the `sql db {}` DSL. It is part of the public +`orm.Connection` interface, and both `orm.DB.execute` and `orm.Tx.execute` +forward to the wrapped connection. + +```v ignore +rows := db.execute("SELECT 1 AS answer, 'ok' AS status")! +assert rows[0].names == ['answer', 'status'] +assert rows[0].vals == ['1', 'ok'] +``` + +Each `orm.Row` stores column values in `vals` and column names in `names`; both +arrays use the same column order. Queries that do not return a result set, such +as DDL or DML statements, return an empty row array. Custom `orm.Connection` +implementers must implement `execute` and should populate `Row.names` whenever +the backend exposes result column metadata. + > [!TIP] > This guide uses the built-in `db.sqlite` module. If you want SQLite without first installing > system-level SQLite development files, the V team also maintains the diff --git a/vlib/orm/orm.v b/vlib/orm/orm.v index 75b25888c..c0afef094 100644 --- a/vlib/orm/orm.v +++ b/vlib/orm/orm.v @@ -76,6 +76,13 @@ pub type Primitive = Null pub struct Null {} +// Row represents a single result row from a raw SQL exec query. +pub struct Row { +pub mut: + vals []string + names []string // column names; populated by drivers that provide them +} + pub enum OperationKind { neq // != eq // == @@ -330,6 +337,7 @@ mut: create(table Table, fields []TableField) ! drop(table Table) ! last_id() int + execute(query string) ![]Row } // TransactionalConnection extends Connection with transaction primitives. diff --git a/vlib/orm/orm_mut_connection_test.v b/vlib/orm/orm_mut_connection_test.v index ab038045b..3f7a6118f 100644 --- a/vlib/orm/orm_mut_connection_test.v +++ b/vlib/orm/orm_mut_connection_test.v @@ -80,6 +80,12 @@ fn (mut db Database) drop(table orm.Table) ! { db.query(query)! } +// execute is used internally by V's ORM for executing raw SQL queries +fn (mut db Database) execute(query string) ![]orm.Row { + db.query(query)! + return []orm.Row{} +} + fn test_orm_mut_connection() { mut db := Database{} @@ -105,3 +111,11 @@ fn test_orm_mut_connection() { assert db.last_query == 'DROP TABLE user;' } + +fn test_orm_execute_on_mock() { + mut db := Database{} + + result := db.execute('SELECT 1 AS test_val')! + assert db.last_query == 'SELECT 1 AS test_val' + assert result.len == 0 +} diff --git a/vlib/orm/orm_null_test.v b/vlib/orm/orm_null_test.v index aaa9d71a3..9399acaae 100644 --- a/vlib/orm/orm_null_test.v +++ b/vlib/orm/orm_null_test.v @@ -91,6 +91,10 @@ fn (db MockDB) drop(table orm.Table) ! { return db.db.drop(table) } +fn (db MockDB) execute(query string) ![]orm.Row { + return db.db.execute(query) +} + fn (db MockDB) last_id() int { return db.db.last_id() } diff --git a/vlib/orm/orm_scope.v b/vlib/orm/orm_scope.v index d84c93512..8a0f98245 100644 --- a/vlib/orm/orm_scope.v +++ b/vlib/orm/orm_scope.v @@ -368,6 +368,11 @@ pub fn (mut db DB) last_id() int { return db.conn.last_id() } +// execute runs a raw SQL query on the wrapped connection and returns the result rows. +pub fn (mut db DB) execute(query string) ![]Row { + return db.conn.execute(query) +} + // DB implements orm.TransactionalConnection (decorator) ----------------------- // unwrap_to_tx extracts a TransactionalConnection from a Connection interface. diff --git a/vlib/orm/orm_scope_test.v b/vlib/orm/orm_scope_test.v index 328c685ca..c128e6dba 100644 --- a/vlib/orm/orm_scope_test.v +++ b/vlib/orm/orm_scope_test.v @@ -1976,3 +1976,18 @@ fn test_data_scope_join_qualifies_table() { assert rows[0].jname == 'main1' assert rows[0].tenant_id == 1 } + +fn test_db_execute_raw_sql() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + defer { + raw_db.close() or {} + } + mut db := orm.new_db(raw_db, orm.DataScope{}) + + result := db.execute('SELECT 1')! + assert result.len == 1 + assert result[0].vals.len == 1 + assert result[0].vals[0] == '1' + assert result[0].names.len == 1 + assert result[0].names[0] == '1' +} diff --git a/vlib/orm/orm_transaction_test.v b/vlib/orm/orm_transaction_test.v index eebce5b02..3423e711f 100644 --- a/vlib/orm/orm_transaction_test.v +++ b/vlib/orm/orm_transaction_test.v @@ -300,3 +300,34 @@ fn test_savepoint_becomes_inactive_when_parent_transaction_closes() { } assert false } + +fn test_orm_execute_in_transaction() { + mut db := setup_tx_db()! + defer { + db.close() or {} + } + + mut tx := orm.begin(mut db)! + result := tx.execute('SELECT 1')! + assert result.len == 1 + assert result[0].vals.len == 1 + assert result[0].vals[0] == '1' + assert result[0].names.len == 1 + assert result[0].names[0] == '1' + tx.commit()! +} + +fn test_orm_execute_after_commit_fails() { + mut db := setup_tx_db()! + defer { + db.close() or {} + } + + mut tx := orm.begin(mut db)! + tx.commit()! + tx.execute('SELECT 1') or { + assert err.msg().contains('inactive') + return + } + assert false +} diff --git a/vlib/orm/transaction.v b/vlib/orm/transaction.v index 2f83e5da1..b094331ce 100644 --- a/vlib/orm/transaction.v +++ b/vlib/orm/transaction.v @@ -247,3 +247,9 @@ pub fn (mut tx Tx) last_id() int { } return tx.inner.conn.last_id() } + +// execute forwards raw SQL execution through the active transaction and returns the result rows. +pub fn (mut tx Tx) execute(query string) ![]Row { + tx.ensure_active('use the transaction')! + return tx.inner.conn.execute(query) +} diff --git a/vlib/v/checker/tests/orm_db_expr_option_error.out b/vlib/v/checker/tests/orm_db_expr_option_error.out index ea35c7edd..5a4312497 100644 --- a/vlib/v/checker/tests/orm_db_expr_option_error.out +++ b/vlib/v/checker/tests/orm_db_expr_option_error.out @@ -54,6 +54,13 @@ vlib/v/checker/tests/orm_db_expr_option_error.vv:22:11: error: `Account` doesn't | ~~~~~~~ 23 | select from Account 24 | }! +vlib/v/checker/tests/orm_db_expr_option_error.vv:22:11: error: `Account` doesn't implement method `execute` of interface `orm.Connection` + 20 | account := Account{} + 21 | + 22 | _ := sql account { + | ~~~~~~~ + 23 | select from Account + 24 | }! vlib/v/checker/tests/orm_db_expr_option_error.vv:26:6: error: `Account` doesn't implement method `select` of interface `orm.Connection` 24 | }! 25 | @@ -103,6 +110,13 @@ vlib/v/checker/tests/orm_db_expr_option_error.vv:26:6: error: `Account` doesn't | ~~~~~~~ 27 | insert account into Account 28 | }! +vlib/v/checker/tests/orm_db_expr_option_error.vv:26:6: error: `Account` doesn't implement method `execute` of interface `orm.Connection` + 24 | }! + 25 | + 26 | sql account { + | ~~~~~~~ + 27 | insert account into Account + 28 | }! vlib/v/checker/tests/orm_db_expr_option_error.vv:30:6: error: expected `sqlite.DB`, not `?sqlite.DB` 28 | }! 29 | -- 2.39.5