Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions vlib/db/mysql/orm.c.v
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Comment thread
Jengro777 marked this conversation as resolved.
} 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 })
Comment thread
Jengro777 marked this conversation as resolved.
}
}

// orm_begin starts a transaction for ORM helpers.
pub fn (mut db DB) orm_begin() ! {
db.begin()!
Expand Down
32 changes: 32 additions & 0 deletions vlib/db/pg/orm.v
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,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
}
Comment thread
Jengro777 marked this conversation as resolved.
}
return orm_rows
}

// orm_begin starts a transaction on this conn.
pub fn (c &Conn) orm_begin() ! {
c.begin_on_conn()!
Expand Down Expand Up @@ -170,6 +189,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;
Expand Down Expand Up @@ -217,6 +243,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) {
Expand Down
17 changes: 10 additions & 7 deletions vlib/db/pg/pg.c.v
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions vlib/db/pg/pg_result_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
77 changes: 77 additions & 0 deletions vlib/db/pg_sqlite_consistency_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
6 changes: 6 additions & 0 deletions vlib/db/sqlite/orm.v
Original file line number Diff line number Diff line change
Expand Up @@ -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()!
Expand Down
19 changes: 19 additions & 0 deletions vlib/orm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions vlib/orm/orm.v
Original file line number Diff line number Diff line change
Expand Up @@ -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 // ==
Expand Down Expand Up @@ -330,6 +337,7 @@ mut:
create(table Table, fields []TableField) !
drop(table Table) !
last_id() int
execute(query string) ![]Row
Comment thread
medvednikov marked this conversation as resolved.
}

// TransactionalConnection extends Connection with transaction primitives.
Expand Down
14 changes: 14 additions & 0 deletions vlib/orm/orm_mut_connection_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand All @@ -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
}
4 changes: 4 additions & 0 deletions vlib/orm/orm_null_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
5 changes: 5 additions & 0 deletions vlib/orm/orm_scope.v
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions vlib/orm/orm_scope_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Loading
Loading