diff --git a/src-tauri/src/drivers/postgres/binding.rs b/src-tauri/src/drivers/postgres/binding.rs index 7fc7be6d3..642fbac4d 100644 --- a/src-tauri/src/drivers/postgres/binding.rs +++ b/src-tauri/src/drivers/postgres/binding.rs @@ -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), @@ -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 `''::`. 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> { + 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, + })) +} diff --git a/src-tauri/src/drivers/postgres/extract/advanced_types.rs b/src-tauri/src/drivers/postgres/extract/advanced_types.rs index 95eb0196d..425383f3f 100644 --- a/src-tauri/src/drivers/postgres/extract/advanced_types.rs +++ b/src-tauri/src/drivers/postgres/extract/advanced_types.rs @@ -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); + +impl<'a> FromSql<'a> for PgVector { + fn from_sql(_ty: &Type, raw: &[u8]) -> Result> { + 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 for JsonValue { + fn from(value: PgVector) -> Self { + let body = value + .0 + .iter() + .map(|v| format_vector_float(*v)) + .collect::>() + .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); + +impl<'a> FromSql<'a> for PgHalfVector { + fn from_sql(_ty: &Type, raw: &[u8]) -> Result> { + 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 for JsonValue { + fn from(value: PgHalfVector) -> Self { + let body = value + .0 + .iter() + .map(|v| format_vector_float(*v)) + .collect::>() + .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> { + 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 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::>() + .join(","); + JsonValue::String(format!("{{{body}}}/{}", value.dim)) + } +} diff --git a/src-tauri/src/drivers/postgres/extract/simple.rs b/src-tauri/src/drivers/postgres/extract/simple.rs index 7d9f7c9e0..f170e2ec0 100644 --- a/src-tauri/src/drivers/postgres/extract/simple.rs +++ b/src-tauri/src/drivers/postgres/extract/simple.rs @@ -159,6 +159,17 @@ pub fn extract_or_null(ty: &Type, buf: &[u8]) -> JsonValue { JsonValue::from(from_sql_or_none::(ty, buf)) } + // pgvector extension types (dynamic OIDs, matched by name) + ref ty if ty.name() == "vector" => { + JsonValue::from(from_sql_or_none::(ty, buf)) + } + ref ty if ty.name() == "halfvec" => { + JsonValue::from(from_sql_or_none::(ty, buf)) + } + ref ty if ty.name() == "sparsevec" => { + JsonValue::from(from_sql_or_none::(ty, buf)) + } + _ => JsonValue::Null, } } @@ -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"; diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 559e5ab38..09ca79e89 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -98,7 +98,13 @@ pub async fn get_columns( let query = r#" SELECT c.column_name::text, - c.data_type::text, + -- information_schema reports extension/composite/enum types as the + -- literal 'USER-DEFINED'; fall back to udt_name for the real type name + -- (e.g. pgvector's 'vector', 'halfvec', 'sparsevec'). + CASE + WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name::text + ELSE c.data_type::text + END AS data_type, c.is_nullable::text, c.column_default::text, c.is_identity::text, @@ -243,7 +249,12 @@ pub async fn get_all_columns_batch( SELECT c.table_name, c.column_name, - c.data_type, + -- See get_columns: map extension/composite/enum types (reported as + -- 'USER-DEFINED') back to their real udt_name (e.g. pgvector 'vector'). + CASE + WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name::text + ELSE c.data_type::text + END AS data_type, c.is_nullable, c.column_default, c.is_identity, @@ -508,9 +519,18 @@ LIMIT 1", .await?; Ok(rows.first().map(|row| { - row.try_get::<_, String>("data_type") - .or_else(|_| row.try_get::<_, String>("udt_name")) - .unwrap_or_else(|_| "unknown".to_string()) + // information_schema reports extension/composite/enum types as the + // literal 'USER-DEFINED'; fall back to udt_name for the real type name + // (e.g. pgvector's 'vector', 'halfvec', 'sparsevec') so the write path + // (update_record/insert_record) resolves the same type as get_columns. + let data_type = row + .try_get::<_, String>("data_type") + .unwrap_or_else(|_| "unknown".to_string()); + if data_type == "USER-DEFINED" { + row.try_get::<_, String>("udt_name").unwrap_or(data_type) + } else { + data_type + } })) } @@ -1167,7 +1187,13 @@ pub async fn get_view_columns( let query = r#" SELECT c.column_name, - c.data_type, + -- information_schema reports extension/composite/enum types as the + -- literal 'USER-DEFINED'; fall back to udt_name for the real type name + -- (e.g. pgvector's 'vector', 'halfvec', 'sparsevec'). + CASE + WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name::text + ELSE c.data_type::text + END AS data_type, c.is_nullable, c.column_default, c.is_identity, diff --git a/src-tauri/src/drivers/postgres/tests.rs b/src-tauri/src/drivers/postgres/tests.rs index d95a0d371..7bcc35752 100644 --- a/src-tauri/src/drivers/postgres/tests.rs +++ b/src-tauri/src/drivers/postgres/tests.rs @@ -1,5 +1,5 @@ use super::binding::{ - PgValueOptions, bind_pg_boolean_string, bind_pg_enum_string, bind_pg_number, + BoundValue, PgValueOptions, bind_pg_boolean_string, bind_pg_enum_string, bind_pg_number, bind_pg_numeric_string, bind_pg_temporal_string, bind_pg_value, build_pk_map_predicate, build_pk_predicate, }; @@ -1070,3 +1070,64 @@ mod routine_management { ); } } + +mod pg_vector_binding_tests { + use super::*; + + fn bind_vector(value: &str, column_type: &str) -> BoundValue { + bind_pg_value( + serde_json::json!(value), + 1, + PgValueOptions { + column_type: Some(column_type), + enum_type: None, + max_blob_size: u64::MAX, + allow_default: false, + }, + ) + .unwrap() + } + + #[test] + fn vector_value_is_inlined_as_typed_literal() { + let bound = bind_vector("[1,2,3]", "vector"); + assert_eq!(bound.sql, "'[1,2,3]'::vector"); + assert!(bound.param.is_none()); + } + + #[test] + fn halfvec_and_sparsevec_are_inlined_with_their_own_type() { + let halfvec = bind_vector("[1,2,3]", "halfvec"); + assert_eq!(halfvec.sql, "'[1,2,3]'::halfvec"); + assert!(halfvec.param.is_none()); + + let sparsevec = bind_vector("{1:1,3:2}/5", "sparsevec"); + assert_eq!(sparsevec.sql, "'{1:1,3:2}/5'::sparsevec"); + assert!(sparsevec.param.is_none()); + } + + #[test] + fn surrounding_whitespace_is_trimmed_before_inlining() { + let bound = bind_vector(" [1,2,3] ", "vector"); + assert_eq!(bound.sql, "'[1,2,3]'::vector"); + } + + #[test] + fn non_numeric_vector_value_is_rejected() { + let result = bind_pg_value( + serde_json::json!("[1,2,3]); DROP TABLE t"), + 1, + PgValueOptions { + column_type: Some("vector"), + enum_type: None, + max_blob_size: u64::MAX, + allow_default: false, + }, + ); + let err = match result { + Ok(_) => panic!("expected non-numeric vector value to be rejected"), + Err(err) => err, + }; + assert!(err.contains("Invalid vector value"), "{err}"); + } +} diff --git a/src/components/ui/FieldEditor.tsx b/src/components/ui/FieldEditor.tsx index 9e2bb0e45..432aa28a9 100644 --- a/src/components/ui/FieldEditor.tsx +++ b/src/components/ui/FieldEditor.tsx @@ -23,6 +23,7 @@ import { import { isLongTextValue, isTextColumn, + isVectorColumn, supportsEmptyString, } from "../../utils/text"; import { getDateInputMode } from "../../utils/dateInput"; @@ -101,7 +102,7 @@ export const FieldEditor = ({ !dateMode && !isEnum && !isSet && - isTextColumn(type) && + (isTextColumn(type) || isVectorColumn(type)) && (isLongTextValue(value) || isLongTextValue(originalValue)); const defaultPlaceholder = placeholder || t("rowEditor.enterValue"); diff --git a/src/utils/text.ts b/src/utils/text.ts index 985f9df39..37d475c2e 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -14,6 +14,11 @@ const TEXT_TYPES = [ "STRING", ]; +// pgvector extension types. Their values arrive as long textual literals +// (e.g. "[-0.0038,-0.0132,…]"), so for preview/editing purposes they behave +// like long text even though they are not character types. +const VECTOR_TYPES = ["VECTOR", "HALFVEC", "SPARSEVEC"]; + export const LONG_TEXT_THRESHOLD = 80; /** @@ -71,6 +76,15 @@ export function supportsEmptyString(dataType: string | undefined): boolean { return isTextColumn(dataType); } +// pgvector columns (vector / halfvec / sparsevec) whose values are rendered +// as long textual literals and benefit from the same preview/editor affordances +// as long text. Matches the base name so "vector(1536)" still resolves. +export function isVectorColumn(dataType: string | undefined): boolean { + if (!dataType) return false; + const normalized = dataType.toUpperCase(); + return VECTOR_TYPES.some((type) => normalized.includes(type)); +} + export function isLongTextValue(value: unknown): boolean { if (typeof value !== "string") return false; if (value.length > LONG_TEXT_THRESHOLD) return true; @@ -81,7 +95,7 @@ export function isLongTextCellTarget( colType: string | undefined, value: unknown, ): boolean { - if (!isTextColumn(colType)) return false; + if (!isTextColumn(colType) && !isVectorColumn(colType)) return false; return isLongTextValue(value); } diff --git a/tests/utils/text.test.ts b/tests/utils/text.test.ts index 575d3659c..7ed0556c0 100644 --- a/tests/utils/text.test.ts +++ b/tests/utils/text.test.ts @@ -6,6 +6,7 @@ import { isLongTextCellTarget, isLongTextValue, isTextColumn, + isVectorColumn, supportsEmptyString, truncateCellPreview, } from "../../src/utils/text"; @@ -124,6 +125,18 @@ describe("text", () => { expect(isLongTextCellTarget(undefined, "x".repeat(200))).toBe(false); }); + it("treats pgvector columns as long-text targets", () => { + const embedding = `[${Array(1536).fill("0.0123").join(",")}]`; + expect(isLongTextCellTarget("vector", embedding)).toBe(true); + expect(isLongTextCellTarget("vector(1536)", embedding)).toBe(true); + expect(isLongTextCellTarget("halfvec", embedding)).toBe(true); + expect(isLongTextCellTarget("sparsevec", "{1:1.5,3:2}/5".repeat(10))).toBe( + true, + ); + // short vectors don't need the expander and render inline + expect(isLongTextCellTarget("vector", "[1,2,3]")).toBe(false); + }); + it("returns false for null / non-string values regardless of column", () => { expect(isLongTextCellTarget("TEXT", null)).toBe(false); expect(isLongTextCellTarget("TEXT", undefined)).toBe(false); @@ -131,6 +144,22 @@ describe("text", () => { }); }); + describe("isVectorColumn", () => { + it("recognizes pgvector types (with and without typmod)", () => { + expect(isVectorColumn("vector")).toBe(true); + expect(isVectorColumn("VECTOR(1536)")).toBe(true); + expect(isVectorColumn("halfvec")).toBe(true); + expect(isVectorColumn("sparsevec")).toBe(true); + }); + + it("returns false for non-vector types and missing type", () => { + expect(isVectorColumn("TEXT")).toBe(false); + expect(isVectorColumn("INTEGER")).toBe(false); + expect(isVectorColumn(undefined)).toBe(false); + expect(isVectorColumn("")).toBe(false); + }); + }); + describe("truncateCellPreview", () => { it("returns short strings untouched without flagging truncation", () => { const result = truncateCellPreview("hello");