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
54 changes: 54 additions & 0 deletions src-tauri/src/drivers/postgres/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,16 @@ fn bind_pg_string(
});
}

// pgvector types must be handled before the generic array/text paths below:
// "[1,2,3]" would otherwise be turned into a PostgreSQL array literal by
// `try_parse_pg_array`, and there is no text->vector cast for a bound param.
if let Some(binding) = options
.column_type
.and_then(|column_type| bind_pg_vector_string(s, column_type))
{
return binding;
}

if let Some(bytes) = crate::drivers::common::decode_blob_wire_format(s, options.max_blob_size) {
return Ok(BoundValue {
sql: format!("${}", placeholder_idx),
Expand Down Expand Up @@ -471,3 +481,47 @@ fn bind_pg_string(
param: Some((Box::new(s.to_string()), Type::TEXT)),
})
}

/// Bind a value into a pgvector column (`vector`, `halfvec`, `sparsevec`).
///
/// pgvector registers no `text -> vector` cast, so a bound TEXT parameter — even
/// via `CAST($N AS vector)` — is rejected by PostgreSQL. The literal only reaches
/// the type's input function when it arrives as an *unknown*-typed literal, so we
/// inline it as `'<value>'::<type>`. To keep that safe, the value is validated
/// against a strict allow-list of characters that make up a vector literal; a
/// non-pgvector column returns `None` so the caller falls through to its normal
/// binding logic.
fn bind_pg_vector_string(s: &str, column_type: &str) -> Option<Result<BoundValue, String>> {
let pg_type = match extract_base_type(column_type).as_str() {
"VECTOR" => "vector",
"HALFVEC" => "halfvec",
"SPARSEVEC" => "sparsevec",
_ => return None,
};

let trimmed = s.trim();

// Characters that can legitimately appear in a vector / halfvec / sparsevec
// literal: digits, sign, decimal point, exponent marker, the bracket/brace
// delimiters, element separators, the sparsevec index (':') and dimension
// ('/') separators, and whitespace. Anything else cannot be inlined safely.
let is_vector_literal = !trimmed.is_empty()
&& trimmed.chars().all(|c| {
c.is_ascii_digit()
|| matches!(
c,
'+' | '-' | '.' | 'e' | 'E' | '[' | ']' | '{' | '}' | ',' | ':' | '/' | ' '
)
});

if !is_vector_literal {
return Some(Err(format!(
"Invalid {pg_type} value: expected a numeric vector literal such as [1,2,3]"
)));
}

Some(Ok(BoundValue {
sql: format!("'{trimmed}'::{pg_type}"),
param: None,
}))
}
186 changes: 186 additions & 0 deletions src-tauri/src/drivers/postgres/extract/advanced_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1545,3 +1545,189 @@ binary_wrapper!(PgDependencies, PG_DEPENDENCIES);
binary_wrapper!(PgNdistinct, PG_NDISTINCT);
binary_wrapper!(PgBrinBloomSummary, PG_BRIN_BLOOM_SUMMARY);
binary_wrapper!(PgBrinMinmaxMultiSummary, PG_BRIN_MINMAX_MULTI_SUMMARY);

// pgvector extension types (vector, halfvec, sparsevec).
//
// These are extension-defined base types, so they have dynamic OIDs and are NOT
// available as `Type::*` constants — `accepts` matches on the type name instead.
// Values are rendered as their canonical pgvector text representation so they show
// up verbatim in the grid and can be edited/round-tripped as text (pgvector accepts
// the same text form on input). Binary layouts follow pgvector's `*_send` functions.

/// Format an `f32` the way pgvector's text output does: shortest round-trippable
/// decimal, e.g. `1.0 -> "1"`, `1.5 -> "1.5"`. Rust's default formatter already
/// produces the shortest round-trip representation.
#[inline]
fn format_vector_float(value: f32) -> String {
value.to_string()
}

/// pgvector `vector`: `int16 dim`, `int16 unused`, then `dim` big-endian `float4`.
pub struct PgVector(Vec<f32>);

impl<'a> FromSql<'a> for PgVector {
fn from_sql(_ty: &Type, raw: &[u8]) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
if raw.len() < 4 {
return Err(format!("expected at least 4 bytes for vector, got {}", raw.len()).into());
}
let dim = u16::from_be_bytes([raw[0], raw[1]]) as usize;
let expected = 4 + dim * 4;
if raw.len() < expected {
return Err(format!(
"vector of dim {dim} expects {expected} bytes, got {}",
raw.len()
)
.into());
}
let mut values = Vec::with_capacity(dim);
for i in 0..dim {
let off = 4 + i * 4;
values.push(f32::from_be_bytes([
raw[off],
raw[off + 1],
raw[off + 2],
raw[off + 3],
]));
}
Ok(Self(values))
}

fn accepts(ty: &Type) -> bool {
ty.name() == "vector"
}
}

impl From<PgVector> for JsonValue {
fn from(value: PgVector) -> Self {
let body = value
.0
.iter()
.map(|v| format_vector_float(*v))
.collect::<Vec<_>>()
.join(",");
JsonValue::String(format!("[{body}]"))
}
}

/// Decode an IEEE 754 half-precision (`binary16`) value into `f32`.
#[inline]
fn f16_bits_to_f32(bits: u16) -> f32 {
let sign = if (bits >> 15) & 1 == 1 { -1.0f32 } else { 1.0f32 };
let exp = (bits >> 10) & 0x1f;
let mant = bits & 0x3ff;
match exp {
0 => sign * (mant as f32) * 2f32.powi(-24), // zero / subnormal
0x1f if mant == 0 => sign * f32::INFINITY,
0x1f => f32::NAN,
_ => sign * (1.0 + (mant as f32) / 1024.0) * 2f32.powi(exp as i32 - 15),
}
}

/// pgvector `halfvec`: `int16 dim`, `int16 unused`, then `dim` big-endian `float2`
/// (half-precision) values.
pub struct PgHalfVector(Vec<f32>);

impl<'a> FromSql<'a> for PgHalfVector {
fn from_sql(_ty: &Type, raw: &[u8]) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
if raw.len() < 4 {
return Err(format!("expected at least 4 bytes for halfvec, got {}", raw.len()).into());
}
let dim = u16::from_be_bytes([raw[0], raw[1]]) as usize;
let expected = 4 + dim * 2;
if raw.len() < expected {
return Err(format!(
"halfvec of dim {dim} expects {expected} bytes, got {}",
raw.len()
)
.into());
}
let mut values = Vec::with_capacity(dim);
for i in 0..dim {
let off = 4 + i * 2;
values.push(f16_bits_to_f32(u16::from_be_bytes([raw[off], raw[off + 1]])));
}
Ok(Self(values))
}

fn accepts(ty: &Type) -> bool {
ty.name() == "halfvec"
}
}

impl From<PgHalfVector> for JsonValue {
fn from(value: PgHalfVector) -> Self {
let body = value
.0
.iter()
.map(|v| format_vector_float(*v))
.collect::<Vec<_>>()
.join(",");
JsonValue::String(format!("[{body}]"))
}
}

/// pgvector `sparsevec`: `int32 dim`, `int32 nnz`, `int32 unused`, then `nnz`
/// big-endian `int32` indices (0-based on the wire), then `nnz` big-endian
/// `float4` values. Text form is `{i1:v1,i2:v2}/dim` with 1-based indices.
pub struct PgSparseVector {
dim: i32,
entries: Vec<(i32, f32)>,
}

impl<'a> FromSql<'a> for PgSparseVector {
fn from_sql(_ty: &Type, raw: &[u8]) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
if raw.len() < 12 {
return Err(
format!("expected at least 12 bytes for sparsevec, got {}", raw.len()).into(),
);
}
let dim = i32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]);
let nnz = i32::from_be_bytes([raw[4], raw[5], raw[6], raw[7]]) as usize;
// raw[8..12] is the unused/reserved header field.
let expected = 12 + nnz * 4 + nnz * 4;
if raw.len() < expected {
return Err(format!(
"sparsevec with {nnz} entries expects {expected} bytes, got {}",
raw.len()
)
.into());
}
let mut entries = Vec::with_capacity(nnz);
let values_off = 12 + nnz * 4;
for i in 0..nnz {
let idx_off = 12 + i * 4;
let index = i32::from_be_bytes([
raw[idx_off],
raw[idx_off + 1],
raw[idx_off + 2],
raw[idx_off + 3],
]);
let val_off = values_off + i * 4;
let value = f32::from_be_bytes([
raw[val_off],
raw[val_off + 1],
raw[val_off + 2],
raw[val_off + 3],
]);
entries.push((index, value));
}
Ok(Self { dim, entries })
}

fn accepts(ty: &Type) -> bool {
ty.name() == "sparsevec"
}
}

impl From<PgSparseVector> for JsonValue {
fn from(value: PgSparseVector) -> Self {
let body = value
.entries
.iter()
// pgvector prints indices 1-based; the wire format stores them 0-based.
.map(|(idx, v)| format!("{}:{}", idx + 1, format_vector_float(*v)))
.collect::<Vec<_>>()
.join(",");
JsonValue::String(format!("{{{body}}}/{}", value.dim))
}
}
74 changes: 74 additions & 0 deletions src-tauri/src/drivers/postgres/extract/simple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@ pub fn extract_or_null(ty: &Type, buf: &[u8]) -> JsonValue {
JsonValue::from(from_sql_or_none::<advanced_types::PgMcvList>(ty, buf))
}

// pgvector extension types (dynamic OIDs, matched by name)
ref ty if ty.name() == "vector" => {
JsonValue::from(from_sql_or_none::<advanced_types::PgVector>(ty, buf))
}
ref ty if ty.name() == "halfvec" => {
JsonValue::from(from_sql_or_none::<advanced_types::PgHalfVector>(ty, buf))
}
ref ty if ty.name() == "sparsevec" => {
JsonValue::from(from_sql_or_none::<advanced_types::PgSparseVector>(ty, buf))
}

_ => JsonValue::Null,
}
}
Expand Down Expand Up @@ -1030,6 +1041,69 @@ mod tests {
);
}

fn pgvector_type(name: &str, oid: u32) -> Type {
Type::new(name.to_string(), oid, Kind::Simple, "public".to_string())
}

#[test]
fn test_pgvector_vector() {
// vector [1, 2, 3.5]: int16 dim, int16 unused, then dim x float4 (big-endian)
let mut buf = Vec::new();
buf.extend_from_slice(&3u16.to_be_bytes()); // dim
buf.extend_from_slice(&0u16.to_be_bytes()); // unused
buf.extend_from_slice(&1.0f32.to_be_bytes());
buf.extend_from_slice(&2.0f32.to_be_bytes());
buf.extend_from_slice(&3.5f32.to_be_bytes());
assert_eq!(
extract_or_null(&pgvector_type("vector", 20000), &buf),
JsonValue::String("[1,2,3.5]".to_string())
);
}

#[test]
fn test_pgvector_vector_empty() {
let mut buf = Vec::new();
buf.extend_from_slice(&0u16.to_be_bytes()); // dim
buf.extend_from_slice(&0u16.to_be_bytes()); // unused
assert_eq!(
extract_or_null(&pgvector_type("vector", 20000), &buf),
JsonValue::String("[]".to_string())
);
}

#[test]
fn test_pgvector_halfvec() {
// halfvec [1, 2]: int16 dim, int16 unused, then dim x float2 (half-precision)
// 1.0 = 0x3C00, 2.0 = 0x4000
let mut buf = Vec::new();
buf.extend_from_slice(&2u16.to_be_bytes()); // dim
buf.extend_from_slice(&0u16.to_be_bytes()); // unused
buf.extend_from_slice(&0x3C00u16.to_be_bytes()); // 1.0
buf.extend_from_slice(&0x4000u16.to_be_bytes()); // 2.0
assert_eq!(
extract_or_null(&pgvector_type("halfvec", 20001), &buf),
JsonValue::String("[1,2]".to_string())
);
}

#[test]
fn test_pgvector_sparsevec() {
// sparsevec {1:1.5,3:2}/5: int32 dim, int32 nnz, int32 unused,
// then nnz x int32 indices (0-based), then nnz x float4 values.
let mut buf = Vec::new();
buf.extend_from_slice(&5i32.to_be_bytes()); // dim
buf.extend_from_slice(&2i32.to_be_bytes()); // nnz
buf.extend_from_slice(&0i32.to_be_bytes()); // unused
buf.extend_from_slice(&0i32.to_be_bytes()); // index 0 (-> printed as 1)
buf.extend_from_slice(&2i32.to_be_bytes()); // index 2 (-> printed as 3)
buf.extend_from_slice(&1.5f32.to_be_bytes());
buf.extend_from_slice(&2.0f32.to_be_bytes());
assert_eq!(
extract_or_null(&pgvector_type("sparsevec", 20002), &buf),
JsonValue::String("{1:1.5,3:2}/5".to_string())
);
}

#[test]
fn test_citext() {
let buf = b"CaseInsensitiveText";
Expand Down
Loading