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
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ internal extension LibPQPluginConnection {
return ColumnMetadata(
columns: metadata.columns,
columnOids: metadata.columnOids,
columnTypeNames: metadata.columnOids.map(typeNames.name(for:))
columnTypeModifiers: metadata.columnTypeModifiers,
columnTypeNames: zip(metadata.columnOids, metadata.columnTypeModifiers).map(typeNames.name(for:modifier:))
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@ internal extension LibPQPluginConnection {
struct ColumnMetadata {
let columns: [String]
let columnOids: [UInt32]
let columnTypeModifiers: [Int32]
let columnTypeNames: [String]
}

func readColumnMetadata(from result: OpaquePointer) -> ColumnMetadata {
let numFields = Int(PQnfields(result))
var columns: [String] = []
var columnOids: [UInt32] = []
var columnTypeModifiers: [Int32] = []
var columnTypeNames: [String] = []
columns.reserveCapacity(numFields)
columnOids.reserveCapacity(numFields)
columnTypeModifiers.reserveCapacity(numFields)
columnTypeNames.reserveCapacity(numFields)

for i in 0..<numFields {
Expand All @@ -32,10 +35,19 @@ internal extension LibPQPluginConnection {
columns.append("column_\(i)")
}
let oid = UInt32(PQftype(result, Int32(i)))
// Only a column read straight from a table reports its own modifier: `v::vector(3)` over a
// `vector` column reports the cast's, which the column does not enforce.
let modifier = PQftable(result, Int32(i)) == 0 ? -1 : PQfmod(result, Int32(i))
columnOids.append(oid)
columnTypeNames.append(typeNames.name(for: oid))
columnTypeModifiers.append(modifier)
columnTypeNames.append(typeNames.name(for: oid, modifier: modifier))
}
return ColumnMetadata(columns: columns, columnOids: columnOids, columnTypeNames: columnTypeNames)
return ColumnMetadata(
columns: columns,
columnOids: columnOids,
columnTypeModifiers: columnTypeModifiers,
columnTypeNames: columnTypeNames
)
}

func fetchResults(
Expand Down
23 changes: 4 additions & 19 deletions apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1243,27 +1243,12 @@ final class LibPQPluginConnection: @unchecked Sendable {

if status == PGRES_SINGLE_TUPLE {
if !headerSent {
let numFields = Int(PQnfields(result))
var columns: [String] = []
var columnTypeNames: [String] = []
columns.reserveCapacity(numFields)
columnOids.reserveCapacity(numFields)
columnTypeNames.reserveCapacity(numFields)

for i in 0..<numFields {
if let namePtr = PQfname(result, Int32(i)) {
columns.append(String(cString: namePtr))
} else {
columns.append("column_\(i)")
}
let oid = UInt32(PQftype(result, Int32(i)))
columnOids.append(oid)
columnTypeNames.append(typeNames.name(for: oid))
}
let metadata = readColumnMetadata(from: result)
columnOids = metadata.columnOids

continuation.yield(.header(PluginStreamHeader(
columns: columns,
columnTypeNames: columnTypeNames,
columns: metadata.columns,
columnTypeNames: metadata.columnTypeNames,
estimatedRowCount: nil
)))
headerSent = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ final class LibPQTypeNameRegistry: Sendable {
?? PostgreSQLCatalogTypeNames.unresolved
}

func name(for oid: UInt32, modifier: Int32) -> String {
PgvectorTypes.spelling(of: name(for: oid), modifier: modifier)
}

func unresolvedOids(in oids: [UInt32]) -> [UInt32] {
state.withLock { state in
oids.filter { state.learnedNames[$0] == nil && PostgreSQLCatalogTypeNames.builtinTypeName(for: $0) == nil }
Expand Down
40 changes: 40 additions & 0 deletions apple/Plugins/PostgreSQLDriverPlugin/PgvectorTypes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// PgvectorTypes.swift
// PostgreSQLDriverPlugin
//

import Foundation

enum PgvectorTypes {
static let typeNames: Set<String> = ["vector", "halfvec", "sparsevec"]

// A base type, so a user's composite or domain that happens to be named `vector` is not taken for one.
static let probeQuery = """
SELECT t.oid::text, t.typarray::text, t.typname
FROM pg_catalog.pg_type t
WHERE t.typtype = 'b' AND t.typname IN ('vector', 'halfvec', 'sparsevec')
"""

static func names(fromProbeRows rows: [[String?]]) -> [UInt32: String] {
var names: [UInt32: String] = [:]
for row in rows {
guard row.count >= 3,
let oid = row[0].flatMap({ UInt32($0) }),
let name = row[2], typeNames.contains(name) else { continue }
names[oid] = name
if let arrayOid = row[1].flatMap({ UInt32($0) }), arrayOid != 0 {
names[arrayOid] = "\(name)[]"
}
}
return names
}

// pgvector stores the dimension count as the type modifier; -1 is a column declared without one.
static func spelling(of typeName: String, modifier: Int32) -> String {
guard modifier > 0 else { return typeName }
let isArray = typeName.hasSuffix("[]")
let base = isArray ? String(typeName.dropLast(2)) : typeName
guard typeNames.contains(base) else { return typeName }
return "\(base)(\(modifier))" + (isArray ? "[]" : "")
}
}
11 changes: 11 additions & 0 deletions apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
await self?.probeCatalogPresence()
await self?.probePostgisOids()
await self?.probeEnumOids()
await self?.probeVectorTypes()
}
try await core.connect()
}
Expand Down Expand Up @@ -95,6 +96,16 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
}
}

private func probeVectorTypes() async {
do {
let result = try await core.execute(query: PgvectorTypes.probeQuery)
let rows = result.rows.map { row in row.map(\.asText) }
core.mergeCatalogTypeNames(PgvectorTypes.names(fromProbeRows: rows))
} catch {
Self.logger.debug("pgvector type probe failed; vector columns resolve on first use: \(error.localizedDescription)")
}
}

func includesMaterializedViews() -> Bool {
catalogPresence?.hasMaterializedViews ?? versionedCapabilities.hasMaterializedViewsCatalog
}
Expand Down
63 changes: 63 additions & 0 deletions apple/Plugins/PostgreSQLDriverTests/PgvectorTypesTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// PgvectorTypesTests.swift
// TableProTests
//

import Foundation
import Testing

struct PgvectorTypesTests {
@Test("The probe names each pgvector type and its array, and ignores any other row")
func probeRowsNameTypesAndArrays() {
let rows: [[String?]] = [
["16385", "16391", "vector"],
["16498", "16504", "halfvec"],
["16633", "0", "sparsevec"],
["17000", "17001", "geometry"],
["oops", "1", "vector"],
["18000", nil]
]

#expect(PgvectorTypes.names(fromProbeRows: rows) == [
16_385: "vector",
16_391: "vector[]",
16_498: "halfvec",
16_504: "halfvec[]",
16_633: "sparsevec"
])
}

@Test("The probe asks for base types only, so a composite named vector is not one")
func probeQueryAsksForBaseTypes() {
#expect(PgvectorTypes.probeQuery.contains("t.typtype = 'b'"))
#expect(PgvectorTypes.probeQuery.contains("'vector', 'halfvec', 'sparsevec'"))
}

@Test("A positive modifier is the dimension count, an array keeps its brackets after it")
func modifierSpellsDimensions() {
#expect(PgvectorTypes.spelling(of: "vector", modifier: 1_536) == "vector(1536)")
#expect(PgvectorTypes.spelling(of: "halfvec", modifier: 3) == "halfvec(3)")
#expect(PgvectorTypes.spelling(of: "sparsevec", modifier: 1_000) == "sparsevec(1000)")
#expect(PgvectorTypes.spelling(of: "vector[]", modifier: 3) == "vector(3)[]")
}

@Test("An expression's -1 and any other type's modifier leave the name alone")
func otherModifiersLeaveTheName() {
#expect(PgvectorTypes.spelling(of: "vector", modifier: -1) == "vector")
#expect(PgvectorTypes.spelling(of: "varchar", modifier: 54) == "varchar")
#expect(PgvectorTypes.spelling(of: "numeric", modifier: 655_366) == "numeric")
#expect(PgvectorTypes.spelling(of: "ENUM(vector)", modifier: 3) == "ENUM(vector)")
}

@Test("A result column reads the dimension count from the oid the probe registered")
func registrySpellsAProbedOid() {
let registry = LibPQTypeNameRegistry()
registry.merge(PgvectorTypes.names(fromProbeRows: [["16385", "16391", "vector"]]))

#expect(registry.name(for: 16_385, modifier: 1_536) == "vector(1536)")
#expect(registry.name(for: 16_385, modifier: -1) == "vector")
#expect(registry.name(for: 16_391, modifier: 3) == "vector(3)[]")
#expect(registry.name(for: 1_043, modifier: 54) == "varchar")
#expect(registry.unresolvedOids(in: [16_385, 16_391]).isEmpty)
}
}
2 changes: 1 addition & 1 deletion apple/TablePro/Core/Compare/DataCompareOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ internal enum ValueComparisonKind: Hashable, Sendable {
self = .numeric
case .date, .timestamp, .datetime:
self = .temporal
case .text, .boolean, .blob, .json, .enumType, .set, .spatial, .array:
case .text, .boolean, .blob, .json, .enumType, .set, .spatial, .vector, .array:
self = .other
}
}
Expand Down
37 changes: 37 additions & 0 deletions apple/TablePro/Core/DataWrite/RowChangeStatementFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ struct RowChangeStatementFactory {
try refuseServerOwnedEdits(in: changes)
try refuseDefaultOnUpdate(in: changes)
try refuseUnwritableExpressions(in: changes, insertedRowData: insertedRowData)
try refuseMisdimensionedVectors(in: changes, insertedRowData: insertedRowData)
let insertedRowData = insertedRowData.mapValues(leavingServerOwnedColumnsToTheServer)
let changes = changes.map(droppingServerOwnedExpressions)
if let driverStatements = try pluginRowWrites(
Expand Down Expand Up @@ -209,6 +210,42 @@ struct RowChangeStatementFactory {
}
}

// The server refuses the whole save with `expected 3 dimensions, not 2`; this names the column first.
// Text the parser cannot read is left to the server, which owns the grammar.
private func refuseMisdimensionedVectors(
in changes: [RowChange],
insertedRowData: [RowID: [PluginCellValue]]
) throws {
let classifier = ColumnTypeClassifier()
let expected = columnTypeNames.compactMapValues { classifier.classify(rawTypeName: $0).vectorDimensions }
guard !expected.isEmpty else { return }
for change in changes where change.type != .delete {
let kind: RowWriteKind = change.type == .insert ? .insert : .update
var written = change.cellChanges.filter { !$0.newIsExpression }.map { ($0.columnName, $0.newValue) }
if change.type == .insert, let stored = insertedRowData[change.rowID] {
written = zip(columns, stored).enumerated()
.filter { !change.expressionColumns.contains($0.offset) }
.map { ($0.element.0, $0.element.1) }
}
for (columnName, value) in written {
guard let dimensions = expected[columnName],
case .text(let text) = value,
let vector = VectorLiteral.parse(text),
vector.dimensions != dimensions else { continue }
throw DataWriteError.changeRefused(
table: tableName,
kind: kind,
reason: String(
format: String(localized: "%1$@ takes %2$lld dimensions, and the value has %3$lld."),
columnName,
dimensions,
vector.dimensions
)
)
}
}
}

private struct MarkedCell {
let kind: RowWriteKind
let columnName: String
Expand Down
1 change: 1 addition & 0 deletions apple/TablePro/Core/Plugins/PluginResultColumnHints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ extension ColumnType {
case .enumType(_, let values): return .enumType(rawType: rawType, values: values)
case .set(_, let values): return .set(rawType: rawType, values: values)
case .spatial: return .spatial(rawType: rawType)
case .vector: return .vector(rawType: rawType)
case .array(_, let element): return .array(rawType: rawType, element: element)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ extension ColumnType {
return .decimal
case .boolean:
return .boolean
case .date, .timestamp, .datetime, .blob, .json, .spatial:
case .date, .timestamp, .datetime, .blob, .json, .spatial, .vector:
return .other
}
}
Expand Down
26 changes: 22 additions & 4 deletions apple/TablePro/Core/Services/ColumnType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ enum ColumnType: Equatable, Sendable {
case enumType(rawType: String?, values: [String]?)
case set(rawType: String?, values: [String]?)
case spatial(rawType: String?)
case vector(rawType: String?)
indirect case array(rawType: String?, element: ColumnType)

/// Raw database type name (e.g., "LONGTEXT", "VARCHAR(255)", "CLOB")
Expand All @@ -40,7 +41,7 @@ enum ColumnType: Equatable, Sendable {
case .text(let raw), .integer(let raw), .decimal(let raw),
.date(let raw), .timestamp(let raw), .datetime(let raw),
.boolean(let raw), .blob(let raw), .json(let raw),
.spatial(let raw), .array(let raw, _):
.spatial(let raw), .vector(let raw), .array(let raw, _):
return raw
case .enumType(let raw, _), .set(let raw, _):
return raw
Expand All @@ -64,6 +65,7 @@ enum ColumnType: Equatable, Sendable {
case .enumType: return "Enum"
case .set: return "Set"
case .spatial: return "Spatial"
case .vector: return "Vector"
case .array(_, let element): return "\(element.displayName) Array"
}
}
Expand Down Expand Up @@ -178,7 +180,7 @@ enum ColumnType: Equatable, Sendable {
return true
case .enumType(_, let values):
return values?.contains("") ?? false
case .date, .timestamp, .datetime, .integer, .decimal, .boolean, .json, .spatial, .array:
case .date, .timestamp, .datetime, .integer, .decimal, .boolean, .json, .spatial, .vector, .array:
return false
}
}
Expand Down Expand Up @@ -232,14 +234,29 @@ enum ColumnType: Equatable, Sendable {
return .scalar
case .json:
return .json
case .blob, .spatial, .array:
case .blob, .spatial, .vector, .array:
return nil
}
}

/// Whether this array's elements can be edited one at a time
var supportsElementEditing: Bool { arrayElementEditor != nil }

var isVectorType: Bool {
if case .vector = self { return true }
return false
}

// Only a lone number is a dimension count: Oracle spells `VECTOR(3, FLOAT32)` and Snowflake `VECTOR(FLOAT, 256)`.
var vectorDimensions: Int? {
guard case .vector(let rawType?) = self,
let open = rawType.firstIndex(of: "("),
let close = rawType[open...].firstIndex(of: ")") else { return nil }
let modifier = rawType[rawType.index(after: open)..<close].trimmingCharacters(in: .whitespaces)
guard let dimensions = Int(modifier), dimensions > 0 else { return nil }
return dimensions
}

/// Compact lowercase badge label for sidebar
var badgeLabel: String {
switch self {
Expand All @@ -256,6 +273,7 @@ enum ColumnType: Equatable, Sendable {
case .text(let rawType):
return rawType == "RedisRaw" ? "raw" : "string"
case .spatial: return "spatial"
case .vector: return "vector"
case .array(_, let element): return "\(element.badgeLabel)[]"
}
}
Expand All @@ -274,7 +292,7 @@ enum ColumnType: Equatable, Sendable {
return .set(rawType: rawType, values: values)
case .array(let rawType, let element):
return .array(rawType: rawType, element: element.withAllowedValues(values))
case .text, .integer, .decimal, .date, .timestamp, .datetime, .boolean, .blob, .json, .spatial:
case .text, .integer, .decimal, .date, .timestamp, .datetime, .boolean, .blob, .json, .spatial, .vector:
return self
}
}
Expand Down
4 changes: 4 additions & 0 deletions apple/TablePro/Core/Services/ColumnTypeClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@ struct ColumnTypeClassifier {
map[key] = { .spatial(rawType: $0) }
}

for key in ["VECTOR", "HALFVEC", "SPARSEVEC"] {
map[key] = { .vector(rawType: $0) }
}

/// An Oracle `BFILE` cell is the `BFILENAME('DIR', 'file')` call that points at the file, never its bytes,
/// so it edits as text; the hex editor would write bytes the driver refuses for a BFILE.
for key in [
Expand Down
Loading
Loading