Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
16 changes: 16 additions & 0 deletions docs/runtime/sql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,22 @@ const rows = await sql`SELECT * FROM users`.raw();
console.log(rows); // [[Buffer, Buffer], [Buffer, Buffer], [Buffer, Buffer]]
```

### Column metadata

Every result also carries a `.columns` array describing each column, and a `.statement` object (`{ string, columns }`) with the executed SQL. The `type` field is the wire-protocol type identifier — the `pg_type` OID on PostgreSQL, the column-type code on MySQL/MariaDB — which lets driver adapters distinguish columns that produce identical JavaScript values, such as a `jsonb` column versus a `text[]` column that both decode to an array.

```ts
const result = await sql`SELECT '["a","b"]'::jsonb AS data, ARRAY['a','b']::text[] AS tags`;

result.columns;
// PostgreSQL: [
// { name: "data", type: 3802, table: 0, number: 0 }, // jsonb
// { name: "tags", type: 1009, table: 0, number: 0 }, // text[]
// ]
```

PostgreSQL columns expose `{ name, type, table, number }` (matching `postgres.js`); MySQL/MariaDB columns expose `{ name, type, table, length, flags }`. SQLite results do not carry column metadata (`.columns` is `null`).

---

## SQL Fragments
Expand Down
71 changes: 71 additions & 0 deletions packages/bun-types/sql.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,77 @@ declare module "bun" {
values(): Query<T>;
}

/**
* Metadata describing a single column in a query result, derived from the
* database's wire-protocol row description.
*
* Currently populated for PostgreSQL and MySQL/MariaDB. SQLite results do
* not expose this metadata.
*
* @example
* ```ts
* const result = await sql`SELECT id, data FROM posts`;
* console.log(result.columns);
* // PostgreSQL:
* // [
* // { name: "id", type: 23, table: 16388, number: 1 }, // int4
* // { name: "data", type: 3802, table: 16388, number: 2 }, // jsonb
* // ]
* ```
*/
interface ResultColumn {
Comment thread
robobun marked this conversation as resolved.
/**
* Column name as returned by the database.
*/
name: string;
/**
* Column type identifier.
*
* - PostgreSQL: the data type OID from `pg_type` (e.g. `23` = int4,
* `25` = text, `3802` = jsonb, `1009` = text[])
* - MySQL/MariaDB: the protocol column type code
* (e.g. `3` = LONG, `253` = VAR_STRING, `245` = JSON)
*/
type: number;
/**
* Source table identifier.
*
* - PostgreSQL: the OID of the table, or `0` if the column is not a
* simple reference to a table column
* - MySQL/MariaDB: the table alias name
*/
table?: number | string;
/**
* PostgreSQL: the attribute number of the column within its table
* (1-based), `0` if the column is not a simple reference to a table
* column, or negative for system columns (e.g. `ctid`, `xmin`).
*/
number?: number;
Comment thread
robobun marked this conversation as resolved.
/**
* MySQL/MariaDB: the column length.
*/
length?: number;
/**
* MySQL/MariaDB: the column flags bitmask.
*/
flags?: number;
}

/**
* Statement metadata attached to a query result.
*/
interface ResultStatement {
/**
* The SQL text that was sent to the server (after placeholder
* substitution, before parameter binding).
*/
string: string;
/**
* Column metadata for this result set.
*/
columns: ResultColumn[];
}

Comment thread
robobun marked this conversation as resolved.
/**
* Callback function type for transaction contexts
* @param sql Function to execute SQL queries within the transaction
Expand Down
19 changes: 18 additions & 1 deletion src/js/internal/sql/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,26 @@ function wrapError(error: Error | MySQLErrorOptions) {
return new MySQLError(error.message, error);
}
initMySQL(
function onResolveMySQLQuery(query, result, commandTag, count, queries, is_last, last_insert_rowid, affected_rows) {
function onResolveMySQLQuery(
query,
result,
commandTag,
count,
queries,
is_last,
last_insert_rowid,
affected_rows,
statement,
) {
$assert(result instanceof SQLResultArray, "Invalid result array");

result.count = count || 0;
result.lastInsertRowid = last_insert_rowid;
result.affectedRows = affected_rows || 0;
if (statement) {
result.statement = statement;
result.columns = statement.columns;
}

// CALL <proc>() and multi-statement strings can yield several result sets.
// Accumulate until the server clears SERVER_MORE_RESULTS_EXISTS (is_last).
Expand Down Expand Up @@ -88,6 +102,9 @@ export interface MySQLDotZig {
count: number,
queries: any,
is_last: boolean,
last_insert_rowid: number | bigint,
affected_rows: number,
statement: Bun.SQL.ResultStatement | undefined,
) => void,
onRejectQuery: (query: Query<any, any>, err: Error, queries) => void,
) => void;
Expand Down
7 changes: 6 additions & 1 deletion src/js/internal/sql/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ function wrapPostgresError(error: Error | PostgresErrorOptions) {
}

initPostgres(
function onResolvePostgresQuery(query, result, commandTag, count, queries, is_last) {
function onResolvePostgresQuery(query, result, commandTag, count, queries, is_last, statement) {
Comment thread
claude[bot] marked this conversation as resolved.
if (is_last) {
if (queries) {
const queriesIndex = queries.indexOf(query);
Expand All @@ -274,6 +274,10 @@ initPostgres(
}

result.count = count || 0;
if (statement) {
result.statement = statement;
result.columns = statement.columns;
}
const last_result = query[_results];

if (!last_result) {
Expand Down Expand Up @@ -317,6 +321,7 @@ export interface PostgresDotZig {
count: number,
queries: any,
is_last: boolean,
statement: Bun.SQL.ResultStatement | undefined,
) => void,
onRejectQuery: (query: Query<any, any>, err: Error, queries) => void,
) => void;
Expand Down
4 changes: 4 additions & 0 deletions src/js/internal/sql/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ class SQLResultArray<T> extends PublicArray<T> {
public command!: string | null;
public lastInsertRowid!: number | bigint | null;
public affectedRows!: number | bigint | null;
public columns!: Bun.SQL.ResultColumn[] | null;
public statement!: Bun.SQL.ResultStatement | null;

static [Symbol.toStringTag] = "SQLResults";

Expand All @@ -95,6 +97,8 @@ class SQLResultArray<T> extends PublicArray<T> {
command: { value: null, writable: true },
lastInsertRowid: { value: null, writable: true },
affectedRows: { value: null, writable: true },
columns: { value: null, writable: true },
statement: { value: null, writable: true },
});
}

Expand Down
40 changes: 29 additions & 11 deletions src/sql/mysql/protocol/ColumnDefinition41.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ bun_core::declare_scope!(ColumnDefinition41, hidden);
pub struct ColumnDefinition41 {
pub(crate) catalog: Data,
pub(crate) schema: Data,
pub(crate) table: Data,
pub table: Data,
pub(crate) org_table: Data,
pub(crate) name: Data,
pub name: Data,
pub(crate) org_name: Data,
pub(crate) fixed_length_fields_length: u64,
pub character_set: u16,
Expand Down Expand Up @@ -118,7 +118,15 @@ impl ColumnDefinition41 {
BStr::new(self.schema.slice())
);

self.table = reader.encode_len_string()?;
// True once any field surfaced in `result.columns` differs from the previous decode.
let mut changed = false;

// `table`/`name` outlive the read buffer (read at OK/EOF time), so they are owned copies.
let table = reader.encode_len_string()?;
if self.table.slice() != table.slice() {
self.table = Data::create(table.slice()).map_err(|_| AnyMySQLError::OutOfMemory)?;
changed = true;
}
Comment thread
robobun marked this conversation as resolved.
bun_core::scoped_log!(
ColumnDefinition41,
"table: {}",
Expand All @@ -132,7 +140,12 @@ impl ColumnDefinition41 {
BStr::new(self.org_table.slice())
);

self.name = reader.encode_len_string()?;
let name = reader.encode_len_string()?;
if self.name.slice() != name.slice() {
self.name = Data::create(name.slice()).map_err(|_| AnyMySQLError::OutOfMemory)?;
// Byte compare: all-digit aliases like `1` and `01` collapse to the same `Index` below.
changed = true;
}
Comment thread
robobun marked this conversation as resolved.
bun_core::scoped_log!(ColumnDefinition41, "name: {}", BStr::new(self.name.slice()));

self.org_name = reader.encode_len_string()?;
Expand All @@ -153,13 +166,15 @@ impl ColumnDefinition41 {

self.fixed_length_fields_length = reader.encoded_len_int()?;
self.character_set = reader.int::<u16>()?;
self.column_length = reader.int::<u32>()?;
let column_length = reader.int::<u32>()?;
changed |= column_length != self.column_length;
self.column_length = column_length;
// `FieldType` is an exhaustive `#[repr(u8)]` enum, so an unknown wire byte
// fails the whole query with `UnsupportedColumnType` rather than being
// carried through and served as a raw/string cell. Resolves once
// `FieldType` becomes a non-exhaustive newtype-over-u8 (see MySQLTypes.rs).
let type_byte = reader.int::<u8>()?;
self.column_type =
let mut column_type =
FieldType::from_raw(type_byte).ok_or(AnyMySQLError::UnsupportedColumnType)?;
// MariaDB has no MYSQL_TYPE_JSON: JSON columns and JSON function
// results arrive as TEXT/BLOB marked format=json, so remap them onto
Expand All @@ -168,7 +183,7 @@ impl ColumnDefinition41 {
// keeping the row reader aligned.
if json_format
&& matches!(
self.column_type,
column_type,
FieldType::MYSQL_TYPE_BLOB
| FieldType::MYSQL_TYPE_TINY_BLOB
| FieldType::MYSQL_TYPE_MEDIUM_BLOB
Expand All @@ -178,9 +193,13 @@ impl ColumnDefinition41 {
| FieldType::MYSQL_TYPE_VARCHAR
)
{
self.column_type = FieldType::MYSQL_TYPE_JSON;
column_type = FieldType::MYSQL_TYPE_JSON;
}
self.flags = ColumnFlags::from_int(reader.int::<u16>()?);
changed |= column_type != self.column_type;
self.column_type = column_type;
let flags = ColumnFlags::from_int(reader.int::<u16>()?);
changed |= flags != self.flags;
self.flags = flags;
self.decimals = reader.int::<u8>()?;

// `ColumnIdentifier::init` consumes its `Data`. We can't move `self.name`
Expand All @@ -195,12 +214,11 @@ impl ColumnDefinition41 {
// ASAN quarantine (test/regression/issue/28632).
let unchanged = matches!(&self.name_or_index,
ColumnIdentifier::Name(existing) if existing.slice() == self.name.slice());
let mut changed = false;
if !unchanged {
let name_view = Data::Temporary(bun_ptr::RawSlice::new(self.name.slice()));
let rebuilt =
ColumnIdentifier::init(name_view).map_err(|_| AnyMySQLError::OutOfMemory)?;
changed = match (&self.name_or_index, &rebuilt) {
changed |= match (&self.name_or_index, &rebuilt) {
(ColumnIdentifier::Index(prev), ColumnIdentifier::Index(curr)) => prev != curr,
_ => true,
};
Expand Down
18 changes: 14 additions & 4 deletions src/sql/postgres/protocol/FieldDescription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ use crate::postgres::any_postgres_error::AnyPostgresError;
use crate::postgres::postgres_types::{self as types, Int4, Short};
use crate::postgres::protocol::new_reader::NewReader;
use crate::shared::column_identifier::ColumnIdentifier;
use crate::shared::data::Data;

pub struct FieldDescription {
/// Raw wire name for `result.columns`; `name_or_index` may be rewritten to `Duplicate`.
pub name: Data,
/// JavaScriptCore treats numeric property names differently than string property names.
/// so we do the work to figure out if the property name is a number ahead of time.
pub name_or_index: ColumnIdentifier,
pub table_oid: Int4,
pub column_index: Short,
pub type_oid: Int4,
pub binary: bool,
}
Expand All @@ -21,17 +26,19 @@ impl FieldDescription {
pub(crate) fn decode_internal<Container: super::new_reader::ReaderContext>(
reader: &mut NewReader<Container>,
) -> Result<Self, AnyPostgresError> {
let name = reader.read_z()?;
let raw_name = reader.read_z()?;
let name = Data::create(raw_name.slice()).map_err(|_| AnyPostgresError::OutOfMemory)?;

// Field name (null-terminated string)
let field_name = ColumnIdentifier::init(name).map_err(|_| AnyPostgresError::OutOfMemory)?;
let field_name =
ColumnIdentifier::init(raw_name).map_err(|_| AnyPostgresError::OutOfMemory)?;
// Table OID (4 bytes)
// If the field can be identified as a column of a specific table, the object ID of the table; otherwise zero.
reader.int4()?;
let table_oid = reader.int4()?;

// Column attribute number (2 bytes)
// If the field can be identified as a column of a specific table, the attribute number of the column; otherwise zero.
reader.short()?;
let column_index = reader.short()?;

// Data type OID (4 bytes)
// The object ID of the field's data type. The type modifier (see pg_attribute.atttypmod). The meaning of the modifier is type-specific.
Expand All @@ -49,6 +56,9 @@ impl FieldDescription {
_ => return Err(AnyPostgresError::UnknownFormatCode),
};
Ok(Self {
name,
table_oid,
column_index,
type_oid,
binary,
name_or_index: field_name,
Expand Down
Loading
Loading