From 6cfe11e374f88e5a116ddd404c75f941383fb3e2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 03:58:21 +0700 Subject: [PATCH 1/4] feat(datagrid): show pgvector columns as vectors, not long text --- ...bPQPluginConnection+CatalogTypeNames.swift | 3 +- .../LibPQPluginConnection+ResultReading.swift | 14 +- .../LibPQPluginConnection.swift | 23 +- .../LibPQTypeNameRegistry.swift | 4 + .../PgvectorTypes.swift | 41 ++++ .../PostgreSQLPluginDriver.swift | 11 + .../PgvectorTypesTests.swift | 63 +++++ .../Core/Compare/DataCompareOptions.swift | 2 +- .../DataWrite/RowChangeStatementFactory.swift | 37 +++ .../Plugins/PluginResultColumnHints.swift | 1 + .../ColumnType+PluginColumnKind.swift | 2 +- apple/TablePro/Core/Services/ColumnType.swift | 26 ++- .../Core/Services/ColumnTypeClassifier.swift | 4 + .../Formatting/CellDisplayFormatter.swift | 2 + .../Services/Formatting/VectorLiteral.swift | 217 ++++++++++++++++++ .../Highlight/HighlightCondition.swift | 2 +- .../Utilities/SQL/ColumnTypeSQLQuoting.swift | 6 +- .../Core/Utilities/SQL/JsonRowConverter.swift | 2 +- .../Models/Query/ResultChartColumn.swift | 2 +- .../Models/Query/SelectionSummary.swift | 2 +- .../TablePro/Models/UI/FieldEditorKind.swift | 1 + .../Models/UI/InspectorFieldLayout.swift | 4 +- .../Models/UI/JSON/JSONRowNodeBuilder.swift | 2 +- .../Views/Filter/CellFilterMenuBuilder.swift | 2 +- .../Results/CellInteractionResolver.swift | 13 ++ .../Views/Results/DataGridCoordinator.swift | 2 +- .../Extensions/DataGridView+Click.swift | 16 ++ .../Extensions/DataGridView+Popovers.swift | 33 +++ .../Views/Results/VectorValueView.swift | 203 ++++++++++++++++ .../FieldEditors/FieldEditorContent.swift | 8 +- .../FieldEditors/VectorFieldEditorView.swift | 64 ++++++ .../RowInspector/InspectorFieldRow.swift | 2 +- .../Views/RowInspector/RowInspectorView.swift | 2 +- .../FieldEditors/FieldEditorResolver.swift | 6 +- ...RowChangeStatementFactoryVectorTests.swift | 81 +++++++ .../Services/CellDisplayFormatterTests.swift | 19 ++ .../Core/Services/ColumnTypeVectorTests.swift | 61 +++++ .../Formatting/VectorLiteralTests.swift | 91 ++++++++ .../CellInteractionResolverTests.swift | 36 +++ .../Shared/FieldEditorResolverTests.swift | 25 ++ 40 files changed, 1089 insertions(+), 46 deletions(-) create mode 100644 apple/Plugins/PostgreSQLDriverPlugin/PgvectorTypes.swift create mode 100644 apple/Plugins/PostgreSQLDriverTests/PgvectorTypesTests.swift create mode 100644 apple/TablePro/Core/Services/Formatting/VectorLiteral.swift create mode 100644 apple/TablePro/Views/Results/VectorValueView.swift create mode 100644 apple/TablePro/Views/RowInspector/FieldEditors/VectorFieldEditorView.swift create mode 100644 apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryVectorTests.swift create mode 100644 apple/TableProTests/Core/Services/ColumnTypeVectorTests.swift create mode 100644 apple/TableProTests/Core/Services/Formatting/VectorLiteralTests.swift diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+CatalogTypeNames.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+CatalogTypeNames.swift index ec1f281522..a76530c883 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+CatalogTypeNames.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+CatalogTypeNames.swift @@ -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:)) ) } } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+ResultReading.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+ResultReading.swift index 95ebba0dae..72c0ebe992 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+ResultReading.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+ResultReading.swift @@ -13,6 +13,7 @@ internal extension LibPQPluginConnection { struct ColumnMetadata { let columns: [String] let columnOids: [UInt32] + let columnTypeModifiers: [Int32] let columnTypeNames: [String] } @@ -20,9 +21,11 @@ internal extension LibPQPluginConnection { 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.. 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 } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PgvectorTypes.swift b/apple/Plugins/PostgreSQLDriverPlugin/PgvectorTypes.swift new file mode 100644 index 0000000000..33aa296b6f --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PgvectorTypes.swift @@ -0,0 +1,41 @@ +// +// PgvectorTypes.swift +// PostgreSQLDriverPlugin +// + +import Foundation + +enum PgvectorTypes { + static let typeNames: Set = ["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, which `PQfmod` reports for a table column + // and an explicit cast and leaves at -1 for any other expression. + 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 ? "[]" : "") + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 91220d3841..44076dd252 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -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() } @@ -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 } diff --git a/apple/Plugins/PostgreSQLDriverTests/PgvectorTypesTests.swift b/apple/Plugins/PostgreSQLDriverTests/PgvectorTypesTests.swift new file mode 100644 index 0000000000..118e1e3a69 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverTests/PgvectorTypesTests.swift @@ -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) + } +} diff --git a/apple/TablePro/Core/Compare/DataCompareOptions.swift b/apple/TablePro/Core/Compare/DataCompareOptions.swift index 78453aefc1..1a591e9426 100644 --- a/apple/TablePro/Core/Compare/DataCompareOptions.swift +++ b/apple/TablePro/Core/Compare/DataCompareOptions.swift @@ -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 } } diff --git a/apple/TablePro/Core/DataWrite/RowChangeStatementFactory.swift b/apple/TablePro/Core/DataWrite/RowChangeStatementFactory.swift index ca7ed17e4b..b1be57ee9c 100644 --- a/apple/TablePro/Core/DataWrite/RowChangeStatementFactory.swift +++ b/apple/TablePro/Core/DataWrite/RowChangeStatementFactory.swift @@ -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( @@ -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 diff --git a/apple/TablePro/Core/Plugins/PluginResultColumnHints.swift b/apple/TablePro/Core/Plugins/PluginResultColumnHints.swift index ef52f9af71..39967acf61 100644 --- a/apple/TablePro/Core/Plugins/PluginResultColumnHints.swift +++ b/apple/TablePro/Core/Plugins/PluginResultColumnHints.swift @@ -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) } } diff --git a/apple/TablePro/Core/Services/ColumnType+PluginColumnKind.swift b/apple/TablePro/Core/Services/ColumnType+PluginColumnKind.swift index c9825310a2..e97c496339 100644 --- a/apple/TablePro/Core/Services/ColumnType+PluginColumnKind.swift +++ b/apple/TablePro/Core/Services/ColumnType+PluginColumnKind.swift @@ -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 } } diff --git a/apple/TablePro/Core/Services/ColumnType.swift b/apple/TablePro/Core/Services/ColumnType.swift index 6648e9c8f9..63145b21f9 100644 --- a/apple/TablePro/Core/Services/ColumnType.swift +++ b/apple/TablePro/Core/Services/ColumnType.swift @@ -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") @@ -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 @@ -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" } } @@ -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 } } @@ -232,7 +234,7 @@ enum ColumnType: Equatable, Sendable { return .scalar case .json: return .json - case .blob, .spatial, .array: + case .blob, .spatial, .vector, .array: return nil } } @@ -240,6 +242,21 @@ enum ColumnType: Equatable, Sendable { /// 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).. 0 else { return nil } + return dimensions + } + /// Compact lowercase badge label for sidebar var badgeLabel: String { switch self { @@ -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)[]" } } @@ -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 } } diff --git a/apple/TablePro/Core/Services/ColumnTypeClassifier.swift b/apple/TablePro/Core/Services/ColumnTypeClassifier.swift index dd16c7b555..1d8f491ac4 100644 --- a/apple/TablePro/Core/Services/ColumnTypeClassifier.swift +++ b/apple/TablePro/Core/Services/ColumnTypeClassifier.swift @@ -228,6 +228,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 [ diff --git a/apple/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift b/apple/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift index 8507573bcb..d2ae1e4be2 100644 --- a/apple/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift +++ b/apple/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift @@ -44,6 +44,8 @@ enum CellDisplayFormatter { ) { displayValue = formatted } + } else if columnType.isVectorType { + displayValue = VectorLiteral.summary(of: displayValue) ?? displayValue } else if BlobFormattingService.shared.requiresFormatting(columnType: columnType) { displayValue = BlobFormattingService.shared.formatIfNeeded( displayValue, columnType: columnType, for: .grid diff --git a/apple/TablePro/Core/Services/Formatting/VectorLiteral.swift b/apple/TablePro/Core/Services/Formatting/VectorLiteral.swift new file mode 100644 index 0000000000..4e77cf8e38 --- /dev/null +++ b/apple/TablePro/Core/Services/Formatting/VectorLiteral.swift @@ -0,0 +1,217 @@ +// +// VectorLiteral.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// pgvector's text form: `[1,2.5,-3e-05]` for vector and halfvec, `{1:2,3:0.5}/5` for sparsevec (1-based indices). +internal struct VectorLiteral: Equatable, Sendable { + internal struct Element: Equatable, Sendable, Identifiable { + internal let index: Int + internal let text: String + + internal var id: Int { index } + } + + internal let dimensions: Int + internal let elements: [Element] + internal let isSparse: Bool + + // Range stays the server's call: vector and halfvec hold different ranges and the type is not known here. + internal static func parse(_ text: String) -> VectorLiteral? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("[") { return parseDense(trimmed) } + if trimmed.hasPrefix("{") { return parseSparse(trimmed) } + return nil + } + + private static func parseDense(_ text: String) -> VectorLiteral? { + guard text.count >= 2, text.hasSuffix("]") else { return nil } + let body = text.dropFirst().dropLast() + guard !body.allSatisfy(\.isWhitespace) else { + return VectorLiteral(dimensions: 0, elements: [], isSparse: false) + } + var elements: [Element] = [] + for (offset, token) in body.split(separator: ",", omittingEmptySubsequences: false).enumerated() { + guard let number = finiteNumber(token) else { return nil } + elements.append(Element(index: offset + 1, text: number)) + } + return VectorLiteral(dimensions: elements.count, elements: elements, isSparse: false) + } + + private static func parseSparse(_ text: String) -> VectorLiteral? { + guard let close = text.lastIndex(of: "}") else { return nil } + let tail = text[text.index(after: close)...].trimmingCharacters(in: .whitespaces) + guard tail.hasPrefix("/"), + let dimensions = Int(tail.dropFirst().trimmingCharacters(in: .whitespaces)), + dimensions > 0 else { return nil } + let body = text[text.index(after: text.startIndex).. = [] + for token in body.split(separator: ",", omittingEmptySubsequences: false) { + let pair = token.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard pair.count == 2, + let index = Int(pair[0].trimmingCharacters(in: .whitespaces)), + (1...dimensions).contains(index), + seen.insert(index).inserted, + let number = finiteNumber(pair[1]) else { return nil } + elements.append(Element(index: index, text: number)) + } + return VectorLiteral( + dimensions: dimensions, + elements: elements.sorted { $0.index < $1.index }, + isSparse: true + ) + } + + private static func finiteNumber(_ token: Substring) -> String? { + let trimmed = token.trimmingCharacters(in: .whitespaces) + guard let value = Double(trimmed), value.isFinite else { return nil } + return trimmed + } + + internal var jsonText: String { + if isSparse { + let members = elements.map { "\"\($0.index)\":\(Self.jsonNumber($0.text))" } + return "{" + members.joined(separator: ",") + "}" + } + return "[" + elements.map { Self.jsonNumber($0.text) }.joined(separator: ",") + "]" + } + + private static func jsonNumber(_ text: String) -> String { + JsonNumberNormalizer.numberLiteral(from: text) ?? Double(text).map { "\($0)" } ?? text + } +} + +// MARK: - Grid summary + +extension VectorLiteral { + // The grid formats every row it prewarms and a 1,536-dimension value is 15 KB, so this is one byte pass + // that keeps only the leading elements. + internal static func summary(of text: String, leadingCount: Int = 3) -> String? { + guard let shape = VectorTextShape(text, keeping: leadingCount) else { return nil } + let (open, close) = shape.isSparse ? ("{", "}") : ("[", "]") + var shown = shape.leading.joined(separator: ", ") + if shape.elementCount > shape.leading.count { + shown += shape.leading.isEmpty ? "…" : ", …" + } + return "\(open)\(shown)\(close) \(dimensionsLabel(shape.dimensions))" + } + + internal static func isVectorText(_ text: String) -> Bool { + VectorTextShape(text, keeping: 0) != nil + } + + internal static func dimensionsLabel(_ count: Int) -> String { + count == 1 ? String(localized: "1 dim") : String(format: String(localized: "%lld dims"), count) + } +} + +private struct VectorTextShape { + let isSparse: Bool + let elementCount: Int + let dimensions: Int + let leading: [String] + + private static let comma = UInt8(ascii: ",") + private static let colon = UInt8(ascii: ":") + private static let slash = UInt8(ascii: "/") + + init?(_ text: String, keeping leadingCount: Int) { + let bytes = Array(text.utf8) + var start = 0 + var end = bytes.count + while start < end, Self.isWhitespace(bytes[start]) { start += 1 } + while end > start, Self.isWhitespace(bytes[end - 1]) { end -= 1 } + guard end - start >= 2 else { return nil } + + let isSparse: Bool + let bodyEnd: Int + let declaredDimensions: Int? + switch bytes[start] { + case UInt8(ascii: "["): + guard bytes[end - 1] == UInt8(ascii: "]") else { return nil } + isSparse = false + bodyEnd = end - 1 + declaredDimensions = nil + case UInt8(ascii: "{"): + guard let close = bytes[start..) -> Int? { + let trimmed = tail.drop(while: isWhitespace) + guard trimmed.first == slash else { return nil } + let digits = trimmed.dropFirst().drop(while: isWhitespace) + guard !digits.isEmpty, digits.allSatisfy(isDigit) else { return nil } + return String(bytes: digits, encoding: .utf8).flatMap { Int($0) } + } + + private static func isElement(_ token: ArraySlice, sparse: Bool) -> Bool { + guard sparse else { return isNumber(token) } + guard let colonIndex = token.firstIndex(of: colon) else { return false } + let index = token[token.startIndex..) -> Bool { + var sawDigit = false + for byte in token where !isWhitespace(byte) { + if isDigit(byte) { + sawDigit = true + continue + } + switch byte { + case UInt8(ascii: "."), UInt8(ascii: "-"), UInt8(ascii: "+"), UInt8(ascii: "e"), UInt8(ascii: "E"): + continue + default: + return false + } + } + return sawDigit + } + + private static func display(_ token: ArraySlice) -> String { + String(bytes: token.filter { !isWhitespace($0) }, encoding: .utf8) ?? "" + } + + private static func isWhitespace(_ byte: UInt8) -> Bool { + byte == UInt8(ascii: " ") || byte == UInt8(ascii: "\t") || byte == UInt8(ascii: "\n") || byte == UInt8(ascii: "\r") + } + + private static func isDigit(_ byte: UInt8) -> Bool { + byte >= UInt8(ascii: "0") && byte <= UInt8(ascii: "9") + } +} diff --git a/apple/TablePro/Core/Services/Highlight/HighlightCondition.swift b/apple/TablePro/Core/Services/Highlight/HighlightCondition.swift index c486cc4733..094ad197df 100644 --- a/apple/TablePro/Core/Services/Highlight/HighlightCondition.swift +++ b/apple/TablePro/Core/Services/Highlight/HighlightCondition.swift @@ -211,7 +211,7 @@ struct HighlightCondition { return .numeric case .boolean: return .boolean - case .text, .date, .timestamp, .datetime, .blob, .json, .enumType, .set, .spatial, .array, .none: + case .text, .date, .timestamp, .datetime, .blob, .json, .enumType, .set, .spatial, .vector, .array, .none: return .text } } diff --git a/apple/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift b/apple/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift index 3a1d633a1f..4b15e11818 100644 --- a/apple/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift +++ b/apple/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift @@ -20,7 +20,7 @@ internal enum ColumnTypeSQLQuoting { return RowValueCopyFormatter.isIntegerLiteral(value) case .decimal: return PluginNumericLiteral.isValid(value) - case .text, .date, .timestamp, .datetime, .boolean, .blob, .json, .enumType, .set, .spatial, .array: + case .text, .date, .timestamp, .datetime, .boolean, .blob, .json, .enumType, .set, .spatial, .vector, .array: return false } } @@ -30,7 +30,7 @@ internal enum ColumnTypeSQLQuoting { switch type { case .text, .enumType, .set: return true - case .integer, .decimal, .date, .timestamp, .datetime, .boolean, .blob, .json, .spatial, .array: + case .integer, .decimal, .date, .timestamp, .datetime, .boolean, .blob, .json, .spatial, .vector, .array: return false } } @@ -55,7 +55,7 @@ internal enum ColumnTypeSQLQuoting { case .text(let rawType): guard let rawType else { return true } return !typesWithoutEquality.contains(baseName(of: rawType)) - case .integer, .decimal, .date, .timestamp, .datetime, .boolean, .enumType, .set: + case .integer, .decimal, .date, .timestamp, .datetime, .boolean, .enumType, .set, .vector: return true case .blob, .json, .spatial, .array: return false diff --git a/apple/TablePro/Core/Utilities/SQL/JsonRowConverter.swift b/apple/TablePro/Core/Utilities/SQL/JsonRowConverter.swift index 669b93f7df..a2ea7b80e7 100644 --- a/apple/TablePro/Core/Utilities/SQL/JsonRowConverter.swift +++ b/apple/TablePro/Core/Utilities/SQL/JsonRowConverter.swift @@ -91,7 +91,7 @@ internal struct JsonRowConverter { return formatBoolean(value) case .json: return formatJson(value) - case .blob, .text, .date, .timestamp, .datetime, .enumType, .set, .spatial, .array: + case .blob, .text, .date, .timestamp, .datetime, .enumType, .set, .spatial, .vector, .array: return quotedEscaped(value) } } diff --git a/apple/TablePro/Models/Query/ResultChartColumn.swift b/apple/TablePro/Models/Query/ResultChartColumn.swift index 945efb4f34..3dd9652fd1 100644 --- a/apple/TablePro/Models/Query/ResultChartColumn.swift +++ b/apple/TablePro/Models/Query/ResultChartColumn.swift @@ -36,7 +36,7 @@ struct ResultChartColumn: Identifiable, Equatable, Sendable { return .date case .text, .boolean, .enumType, .set: return .category - case .blob, .json, .spatial, .array: + case .blob, .json, .spatial, .vector, .array: return nil } } diff --git a/apple/TablePro/Models/Query/SelectionSummary.swift b/apple/TablePro/Models/Query/SelectionSummary.swift index a3e55fa566..af5da02cb1 100644 --- a/apple/TablePro/Models/Query/SelectionSummary.swift +++ b/apple/TablePro/Models/Query/SelectionSummary.swift @@ -87,7 +87,7 @@ internal extension SelectionSummaryColumnPolicy { case .text(let rawType): guard let rawType, !rawType.isEmpty else { return .numericIfParsable } return .countOnly - case .date, .timestamp, .datetime, .boolean, .blob, .json, .enumType, .set, .spatial, .array: + case .date, .timestamp, .datetime, .boolean, .blob, .json, .enumType, .set, .spatial, .vector, .array: return .countOnly } } diff --git a/apple/TablePro/Models/UI/FieldEditorKind.swift b/apple/TablePro/Models/UI/FieldEditorKind.swift index 9113eadbb3..9419779ab8 100644 --- a/apple/TablePro/Models/UI/FieldEditorKind.swift +++ b/apple/TablePro/Models/UI/FieldEditorKind.swift @@ -19,6 +19,7 @@ internal enum FieldEditorKind: Equatable { /// An ordered list of elements, each edited on its own. The payload says whether an element is /// a one-line value or a JSON document, which are two different editors over the same list. case arrayElements(element: ArrayElementEditor, values: [String]) + case vector case typePicker /// A value with suggestions the user may ignore: the menu writes correct SQL, typing writes /// your own. `enumPicker` cannot serve, because its list is the whole set of legal values. diff --git a/apple/TablePro/Models/UI/InspectorFieldLayout.swift b/apple/TablePro/Models/UI/InspectorFieldLayout.swift index 23a7b4a9ad..a46eb850c8 100644 --- a/apple/TablePro/Models/UI/InspectorFieldLayout.swift +++ b/apple/TablePro/Models/UI/InspectorFieldLayout.swift @@ -43,7 +43,7 @@ internal enum InspectorFieldLayout: Equatable { /// the other while the user is typing into it. private static func dataLayout(for kind: FieldEditorKind) -> InspectorFieldLayout { switch kind { - case .singleLine, .boolean, .enumPicker, .setPicker, .arrayElements, .schemaText, .typePicker, + case .singleLine, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .schemaText, .typePicker, .valuePicker, .multiLine, .json, .phpSerialized, .blobHex, .image, .geometry: return .stacked } @@ -53,7 +53,7 @@ internal enum InspectorFieldLayout: Equatable { /// high. The editors that need the pane's width still take it. private static func schemaLayout(for kind: FieldEditorKind) -> InspectorFieldLayout { switch kind { - case .singleLine, .boolean, .enumPicker, .setPicker, .arrayElements, .schemaText, .typePicker, + case .singleLine, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .schemaText, .typePicker, .valuePicker: return .inline case .multiLine, .json, .phpSerialized, .blobHex, .image, .geometry: diff --git a/apple/TablePro/Models/UI/JSON/JSONRowNodeBuilder.swift b/apple/TablePro/Models/UI/JSON/JSONRowNodeBuilder.swift index 2f99c1a2c3..fde96c7266 100644 --- a/apple/TablePro/Models/UI/JSON/JSONRowNodeBuilder.swift +++ b/apple/TablePro/Models/UI/JSON/JSONRowNodeBuilder.swift @@ -162,7 +162,7 @@ enum JSONRowNodeBuilder { case .isFalse: return .bool(false) default: return .string(text) } - case .text, .date, .timestamp, .datetime, .blob, .json, .enumType, .set, .spatial, .array: + case .text, .date, .timestamp, .datetime, .blob, .json, .enumType, .set, .spatial, .vector, .array: return .string(text) } } diff --git a/apple/TablePro/Views/Filter/CellFilterMenuBuilder.swift b/apple/TablePro/Views/Filter/CellFilterMenuBuilder.swift index e3b0de641f..31eb3b065d 100644 --- a/apple/TablePro/Views/Filter/CellFilterMenuBuilder.swift +++ b/apple/TablePro/Views/Filter/CellFilterMenuBuilder.swift @@ -77,7 +77,7 @@ enum CellFilterMenuBuilder { switch columnType { case .integer, .decimal, .date, .timestamp, .datetime: return [.equal, .notEqual, .greaterThan, .lessThan] - case .text, .boolean, .enumType, .set, .blob, .json, .spatial, .array: + case .text, .boolean, .enumType, .set, .blob, .json, .spatial, .vector, .array: return [.equal, .notEqual] } } diff --git a/apple/TablePro/Views/Results/CellInteractionResolver.swift b/apple/TablePro/Views/Results/CellInteractionResolver.swift index 5960da8b7f..ce3b13e803 100644 --- a/apple/TablePro/Views/Results/CellInteractionResolver.swift +++ b/apple/TablePro/Views/Results/CellInteractionResolver.swift @@ -50,6 +50,7 @@ internal enum CellInteractionMode: Equatable { case viewPhpSerialized case viewSvg + case viewVector case editInline(value: String) case editOverlay(value: String) @@ -57,6 +58,7 @@ internal enum CellInteractionMode: Equatable { case editJson case editBlob case editSvg + case editVector case blocked } @@ -71,6 +73,11 @@ internal struct CellInteractionResolver { return isReadOnly ? .viewBlob : .editBlob } + // Ahead of the display format: a vector column offers none, so an override is one left from its text days. + if opensVectorViewer(context) { + return isReadOnly ? .viewVector : .editVector + } + if case .image(.svg) = context.detectedContent, context.displayFormatOverride != .raw { return isReadOnly ? .viewSvg : .editSvg } @@ -98,4 +105,10 @@ internal struct CellInteractionResolver { if value.containsLineBreak { return .editOverlay(value: value) } return .editInline(value: value) } + + // The value decides, as it does for an array: NULL and text the viewer cannot list keep the text editor. + private func opensVectorViewer(_ context: CellContext) -> Bool { + guard context.columnType?.isVectorType == true, let value = context.value else { return false } + return VectorLiteral.isVectorText(value) + } } diff --git a/apple/TablePro/Views/Results/DataGridCoordinator.swift b/apple/TablePro/Views/Results/DataGridCoordinator.swift index 4e87200f75..86d8d54195 100644 --- a/apple/TablePro/Views/Results/DataGridCoordinator.swift +++ b/apple/TablePro/Views/Results/DataGridCoordinator.swift @@ -1280,7 +1280,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData for i in 0..= 0, columnIndex < tableRows.columnTypes.count else { return } + guard let literal = cellTypedValue(at: row, column: columnIndex).asText, + VectorLiteral.isVectorText(literal) else { + if isEditable { beginCellEdit(row: row, tableColumnIndex: column) } + return + } + + // Bound to the record, as the JSON editor is: a sort or a filter can move another row under this position. + let rowID = displayRow(at: row)?.id + let expectedDimensions = tableRows.columnTypes[columnIndex].vectorDimensions + let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) + + dismissActiveCellEditorPopover() + activeCellEditorPopover = PopoverPresenter.show( + relativeTo: cellRect, + of: tableView, + behavior: isEditable ? .applicationDefined : .semitransient + ) { [weak self] dismiss in + VectorValueView( + literal: literal, + expectedDimensions: expectedDimensions, + isEditable: isEditable, + onCommit: { newValue in + self?.commitCellEdit(rowID: rowID, fallbackDisplayRow: row, columnIndex: columnIndex, newValue: newValue) + }, + onDismiss: dismiss + ) + } + } + /// Only one cell editor popover is open at a time, and the outgoing one is closed before the /// next is presented rather than after. An `.applicationDefined` popover such as the array /// editor stays on screen until something closes it, so forgetting it would strand an editor diff --git a/apple/TablePro/Views/Results/VectorValueView.swift b/apple/TablePro/Views/Results/VectorValueView.swift new file mode 100644 index 0000000000..65b0a8f72d --- /dev/null +++ b/apple/TablePro/Views/Results/VectorValueView.swift @@ -0,0 +1,203 @@ +// +// VectorValueView.swift +// TablePro +// + +import SwiftUI + +internal struct VectorValueView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared + let expectedDimensions: Int? + let isEditable: Bool + let onCommit: (String) -> Void + let onDismiss: () -> Void + + private let original: String + @State private var text: String + @State private var isEditingText: Bool + @State private var shown: VectorLiteral? + @State private var selection = Set() + + // A value the list cannot read opens on its own text rather than an empty list. + init( + literal: String, + expectedDimensions: Int?, + isEditable: Bool, + onCommit: @escaping (String) -> Void, + onDismiss: @escaping () -> Void + ) { + self.expectedDimensions = expectedDimensions + self.isEditable = isEditable + self.onCommit = onCommit + self.onDismiss = onDismiss + self.original = literal + let parsed = VectorLiteral.parse(literal) + _text = State(initialValue: literal) + _shown = State(initialValue: parsed) + _isEditingText = State(initialValue: parsed == nil) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + if isEditingText { + textEditor + } else { + elementTable + } + Divider() + footer + } + .frame(width: 360, height: Self.height(for: shown?.elements.count ?? 0)) + .onExitCommand(perform: onDismiss) + } + + private static func height(for rowCount: Int) -> CGFloat { + min(460, max(260, CGFloat(rowCount) * 24 + 140)) + } + + private var header: some View { + HStack(spacing: 8) { + dimensionsText + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button(toggleTitle, action: toggleTextEditing) + .buttonStyle(.link) + .font(.caption) + .disabled(isEditingText && VectorLiteral.parse(text) == nil) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + @ViewBuilder + private var dimensionsText: some View { + if let vector = isEditingText ? VectorLiteral.parse(text) : shown { + if vector.isSparse { + Text("^[\(vector.dimensions) dimension](inflect: true), \(vector.elements.count) nonzero") + } else { + Text("^[\(vector.dimensions) dimension](inflect: true)") + } + } else { + Text("Not a vector") + } + } + + private var toggleTitle: LocalizedStringKey { + if isEditingText { return "View Elements" } + return isEditable ? "Edit as Text" : "View as Text" + } + + private var elementTable: some View { + Table(shown?.elements ?? [], selection: $selection) { + TableColumn("Index") { element in + Text(element.index, format: .number) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .width(min: 48, ideal: 64, max: 96) + TableColumn("Value") { element in + Text(element.text) + .font(themeEngine.valueFontSwiftUI) + } + } + .onCopyCommand { + let values = (shown?.elements ?? []).filter { selection.contains($0.id) }.map(\.text) + guard !values.isEmpty else { return [] } + return [NSItemProvider(object: values.joined(separator: "\n") as NSString)] + } + } + + private var textEditor: some View { + VStack(alignment: .leading, spacing: 4) { + TextValueEditor( + text: $text, + isEditable: isEditable, + font: themeEngine.valueFont, + textContainerInset: .zero, + style: .code + ) + .frame(minHeight: 90) + if let problem = textProblem { + Label { + problem + } icon: { + Image(systemName: "exclamationmark.triangle") + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(12) + } + + private var textProblem: Text? { + guard isEditable else { return nil } + guard let vector = VectorLiteral.parse(text) else { + return Text("This is not a vector value. Write it as [1,2,3].") + } + guard let expected = wrongLength(of: vector) else { return nil } + return Text("This column takes ^[\(expected) dimension](inflect: true). The value has \(vector.dimensions).") + } + + private func wrongLength(of vector: VectorLiteral) -> Int? { + guard let expectedDimensions, vector.dimensions != expectedDimensions else { return nil } + return expectedDimensions + } + + private var footer: some View { + HStack(spacing: 8) { + Button("Copy as JSON", action: copyJSON) + .disabled(currentVector == nil) + Spacer() + if isEditable { + Button("Cancel", action: onDismiss) + .keyboardShortcut(.cancelAction) + Button("OK", action: commitAndDismiss) + .keyboardShortcut(.defaultAction) + .disabled(!canCommit) + } else { + Button("Close", action: onDismiss) + .keyboardShortcut(.defaultAction) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var currentVector: VectorLiteral? { + isEditingText ? VectorLiteral.parse(text) : shown + } + + // Text the parser cannot read still goes to the server, which owns the grammar; a length the column + // refuses does not. + private var canCommit: Bool { + guard let vector = VectorLiteral.parse(text) else { return true } + return wrongLength(of: vector) == nil + } + + private func toggleTextEditing() { + if isEditingText { + guard let parsed = VectorLiteral.parse(text) else { return } + shown = parsed + selection = [] + isEditingText = false + } else { + isEditingText = true + } + } + + private func copyJSON() { + guard let vector = currentVector else { return } + ClipboardService.shared.writeText(vector.jsonText) + } + + private func commitAndDismiss() { + if text != original { + onCommit(text) + } + onDismiss() + } +} diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift b/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift index ea7e086721..3f1a8b811b 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift @@ -29,7 +29,7 @@ internal struct FieldEditorContent: View { /// take away the control that changes it back. private var isPicker: Bool { switch kind { - case .boolean, .enumPicker, .setPicker, .arrayElements, .typePicker, .valuePicker: return true + case .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker: return true case .json, .phpSerialized, .image, .geometry, .blobHex, .schemaText, .multiLine, .singleLine: return false } } @@ -41,7 +41,7 @@ internal struct FieldEditorContent: View { switch kind { case .json, .phpSerialized, .multiLine: return true case .geometry(let descriptor): return canExpand(kind: descriptor.textEditorKind, state: state) - case .image, .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .typePicker, + case .image, .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker, .schemaText, .singleLine: return false } @@ -56,7 +56,7 @@ internal struct FieldEditorContent: View { case .blobHex: return 60 case .multiLine: return ResizableFieldMetrics.defaultTextHeight case .geometry(let descriptor): return pillHeight(for: descriptor.textEditorKind) - case .boolean, .enumPicker, .setPicker, .arrayElements, .typePicker, .valuePicker, + case .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker, .schemaText, .singleLine: return nil } @@ -95,6 +95,8 @@ internal struct FieldEditorContent: View { onSetNull: onSetNull, onSetDefault: onSetDefault ) + case .vector: + VectorFieldEditorView(context: context, onSetNull: onSetNull, onSetDefault: onSetDefault) case .typePicker: TypePickerFieldView(context: context, databaseType: databaseType) case .valuePicker(let options): diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/VectorFieldEditorView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/VectorFieldEditorView.swift new file mode 100644 index 0000000000..2fd364060d --- /dev/null +++ b/apple/TablePro/Views/RowInspector/FieldEditors/VectorFieldEditorView.swift @@ -0,0 +1,64 @@ +// +// VectorFieldEditorView.swift +// TablePro +// + +import SwiftUI + +// Shaped like `ArrayFieldEditorView`: the row shows the summary and the viewer commits once, since a +// 1,536-element literal is no value to type into a field. +internal struct VectorFieldEditorView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared + internal let context: FieldEditorContext + internal var onSetNull: (() -> Void)? + internal var onSetDefault: (() -> Void)? + + @State private var isViewerPresented = false + + var body: some View { + Menu { + Button { + isViewerPresented = true + } label: { + Text(context.isReadOnly ? "View Vector…" : "Edit Vector…") + } + if context.canMutate, onSetNull != nil || onSetDefault != nil { + Divider() + if let onSetNull { + Button("Set NULL", action: onSetNull) + } + if let onSetDefault { + Button("Set DEFAULT", action: onSetDefault) + } + } + } label: { + Text(displayLabel) + .font(themeEngine.valueFontSwiftUI) + .foregroundStyle(context.valueState.placeholder == nil ? .primary : .secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .menuStyle(.button) + .buttonStyle(.borderless) + .padding(.horizontal, 4) + .frame(maxWidth: .infinity, minHeight: 22, alignment: .leading) + .background(.quinary, in: RoundedRectangle(cornerRadius: 5)) + .popover(isPresented: $isViewerPresented) { + VectorValueView( + literal: context.valueState.editableText, + expectedDimensions: context.columnType.vectorDimensions, + isEditable: !context.isReadOnly, + onCommit: { context.value.wrappedValue = $0 }, + onDismiss: { isViewerPresented = false } + ) + } + } + + private var displayLabel: String { + if let placeholder = context.valueState.placeholder { return placeholder } + let text = context.valueState.editableText + return VectorLiteral.summary(of: text) ?? text + } +} diff --git a/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift b/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift index 71acd168f7..34c0846545 100644 --- a/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift +++ b/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift @@ -218,7 +218,7 @@ internal struct InspectorFieldRow: View { return nil case .geometry: return nil - case .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .typePicker, .valuePicker, + case .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker, .schemaText, .multiLine, .singleLine: return ThemeEngine.shared.valueFontSwiftUI } diff --git a/apple/TablePro/Views/RowInspector/RowInspectorView.swift b/apple/TablePro/Views/RowInspector/RowInspectorView.swift index 58787acc46..862ec59573 100644 --- a/apple/TablePro/Views/RowInspector/RowInspectorView.swift +++ b/apple/TablePro/Views/RowInspector/RowInspectorView.swift @@ -203,7 +203,7 @@ internal enum InspectorPopOutWindow: Equatable { case .geometry(let descriptor): return resolve(for: descriptor.textEditorKind) case .multiLine, .singleLine, .schemaText, .blobHex, .image, .boolean, - .enumPicker, .setPicker, .arrayElements, .typePicker, .valuePicker: + .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker: return .text } } diff --git a/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift b/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift index b08a6bfed4..1f0e716196 100644 --- a/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift +++ b/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift @@ -37,6 +37,10 @@ internal enum FieldEditorResolver { displayFormatOverride: ValueDisplayFormat? = nil, isBinaryValue: Bool = false ) -> FieldEditorKind { + // Ahead of the display format, as in the grid: a vector column offers none to choose. + if type.isVectorType, let originalValue, VectorLiteral.isVectorText(originalValue) { + return .vector + } let structuredAllowed: Bool if let override = displayFormatOverride { switch override { @@ -134,7 +138,7 @@ internal enum FieldEditorResolver { case .json: guard let originalValue, isGeoJSON(originalValue) else { return nil } return .geometry(GeometryFieldDescriptor(textEditor: .json, source: .jsonColumn)) - case .text, .integer, .decimal, .date, .timestamp, .datetime, .boolean, .blob, .enumType, .set, .array: + case .text, .integer, .decimal, .date, .timestamp, .datetime, .boolean, .blob, .enumType, .set, .vector, .array: return nil } } diff --git a/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryVectorTests.swift b/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryVectorTests.swift new file mode 100644 index 0000000000..a74040dc9b --- /dev/null +++ b/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryVectorTests.swift @@ -0,0 +1,81 @@ +// +// RowChangeStatementFactoryVectorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct RowChangeStatementFactoryVectorTests { + private let columns = ["id", "embedding", "note"] + + private func factory(types: [String: String] = ["id": "integer", "embedding": "vector(3)", "note": "text"]) + -> RowChangeStatementFactory { + RowChangeStatementFactory( + tableName: "items", + schemaName: "public", + columns: columns, + primaryKeyColumns: ["id"], + columnTypeNames: types, + databaseType: .postgresql, + pluginDriver: nil + ) + } + + private func edit(_ value: String, column: String = "embedding", index: Int = 1) -> RowChange { + RowChange( + rowID: .existing(0), + type: .update, + cellChanges: [CellChange(columnIndex: index, columnName: column, oldValue: "[1,2,3]", newValue: .text(value))], + originalRow: ["1", "[1,2,3]", "a"] + ) + } + + private func refusal(_ changes: [RowChange], insertedRowData: [RowID: [PluginCellValue]] = [:]) -> String? { + do { + _ = try factory().statements( + for: changes, + insertedRowData: insertedRowData, + insertedRowIDs: Set(insertedRowData.keys) + ) + return nil + } catch let DataWriteError.changeRefused(_, _, reason) { + return reason + } catch { + Issue.record("Unexpected error: \(error)") + return nil + } + } + + @Test("A value of the wrong length is refused before Save, naming the column and both counts") + func wrongLengthIsRefused() throws { + let reason = try #require(refusal([edit("[1,2]")])) + #expect(reason.contains("embedding")) + #expect(reason.contains("3")) + #expect(reason.contains("2")) + } + + @Test("A new row is checked from the values it was staged with") + func insertIsChecked() { + let rowID = RowID.inserted(UUID()) + let insert = RowChange(rowID: rowID, type: .insert) + #expect(refusal([insert], insertedRowData: [rowID: ["1", "[1,2,3,4]", "a"]]) != nil) + #expect(refusal([insert], insertedRowData: [rowID: ["1", "[1,2,3]", "a"]]) == nil) + } + + @Test("A value of the right length, text the parser cannot read, and other columns all go to the server") + func otherValuesPass() { + #expect(refusal([edit("[4, 5, 6]")]) == nil) + #expect(refusal([edit("[1,2")]) == nil) + #expect(refusal([edit("[1,2]", column: "note", index: 2)]) == nil) + } + + @Test("A column with no declared length is never refused") + func unconstrainedColumnPasses() throws { + let unconstrained = factory(types: ["id": "integer", "embedding": "vector", "note": "text"]) + _ = try unconstrained.statements(for: [edit("[1,2]")]) + } +} diff --git a/apple/TableProTests/Core/Services/CellDisplayFormatterTests.swift b/apple/TableProTests/Core/Services/CellDisplayFormatterTests.swift index e1fcfa2592..22ff752378 100644 --- a/apple/TableProTests/Core/Services/CellDisplayFormatterTests.swift +++ b/apple/TableProTests/Core/Services/CellDisplayFormatterTests.swift @@ -251,3 +251,22 @@ struct CellDisplayFormatterTests { } } } + +@MainActor +struct CellDisplayFormatterVectorTests { + @Test("A vector cell draws its first elements and dimension count, not the whole literal") + func vectorSummary() { + let literal = "[" + (1...1_536).map { "0.\($0)" }.joined(separator: ",") + "]" + let result = CellDisplayFormatter.format(.text(literal), columnType: .vector(rawType: "vector(1536)")) + #expect(result == "[0.1, 0.2, 0.3, …] 1536 dims") + } + + @Test("A display format left from the column's text days keeps the summary, and unreadable text its own display") + func formatAndUnreadable() { + let vector = ColumnType.vector(rawType: "vector(3)") + #expect(CellDisplayFormatter.format(.text("[1,2,3]"), columnType: vector, displayFormat: .raw) == "[1, 2, 3] 3 dims") + #expect(CellDisplayFormatter.format(.text("[1,2,3]"), columnType: vector, displayFormat: .json) == "[1, 2, 3] 3 dims") + #expect(CellDisplayFormatter.format(.text("not a vector"), columnType: vector) == "not a vector") + #expect(CellDisplayFormatter.format(.text("[1,2,3]"), columnType: .text(rawType: "text")) == "[1,2,3]") + } +} diff --git a/apple/TableProTests/Core/Services/ColumnTypeVectorTests.swift b/apple/TableProTests/Core/Services/ColumnTypeVectorTests.swift new file mode 100644 index 0000000000..9ea5a6bf0c --- /dev/null +++ b/apple/TableProTests/Core/Services/ColumnTypeVectorTests.swift @@ -0,0 +1,61 @@ +// +// ColumnTypeVectorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct ColumnTypeVectorTests { + private let classifier = ColumnTypeClassifier() + + @Test("pgvector's three types classify as vectors, with or without a dimension count", arguments: [ + "vector", "vector(1536)", "halfvec(768)", "sparsevec(1000)", "VECTOR", "halfvec", "sparsevec" + ]) + func pgvectorTypesAreVectors(raw: String) { + #expect(classifier.classify(rawTypeName: raw) == .vector(rawType: raw)) + } + + @Test("An array of vectors is an array whose element is a vector") + func vectorArray() { + #expect( + classifier.classify(rawTypeName: "vector(3)[]") + == .array(rawType: "vector(3)[]", element: .vector(rawType: "vector(3)")) + ) + #expect(classifier.classify(rawTypeName: "vector(3)[]").arrayElementEditor == nil) + } + + @Test("The dimension count is a lone number in the modifier and nothing else") + func dimensions() { + #expect(ColumnType.vector(rawType: "vector(1536)").vectorDimensions == 1_536) + #expect(ColumnType.vector(rawType: "sparsevec(1000)").vectorDimensions == 1_000) + #expect(ColumnType.vector(rawType: "vector").vectorDimensions == nil) + #expect(ColumnType.vector(rawType: "VECTOR(3, FLOAT32)").vectorDimensions == nil) + #expect(ColumnType.vector(rawType: "VECTOR(FLOAT, 256)").vectorDimensions == nil) + #expect(ColumnType.vector(rawType: nil).vectorDimensions == nil) + #expect(ColumnType.text(rawType: "varchar(3)").vectorDimensions == nil) + } + + @Test("An emptied vector cell has no empty value to write, so it stages NULL") + func noEmptyValue() { + #expect(!ColumnType.vector(rawType: "vector(3)").hasEmptyValue) + } + + @Test("A vector keeps the name the server declared and reads as a vector in the sidebar") + func declaredNameAndBadge() { + let declared = ColumnType.vector(rawType: "vector").declared(as: "vector(3)") + #expect(declared == .vector(rawType: "vector(3)")) + #expect(declared.badgeLabel == "vector") + #expect(declared.isVectorType) + #expect(!ColumnType.json(rawType: "jsonb").isVectorType) + } + + @Test("A vector keeps its equality filter and is not compared with LIKE uncast") + func filterBehavior() { + let vector = ColumnType.vector(rawType: "vector(3)") + #expect(ColumnTypeSQLQuoting.hasEqualityOperator(vector)) + #expect(!ColumnTypeSQLQuoting.isCharacterType(vector)) + #expect(!ColumnTypeSQLQuoting.isKnownTextLike(vector)) + } +} diff --git a/apple/TableProTests/Core/Services/Formatting/VectorLiteralTests.swift b/apple/TableProTests/Core/Services/Formatting/VectorLiteralTests.swift new file mode 100644 index 0000000000..d392cd8e65 --- /dev/null +++ b/apple/TableProTests/Core/Services/Formatting/VectorLiteralTests.swift @@ -0,0 +1,91 @@ +// +// VectorLiteralTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct VectorLiteralTests { + @Test("A dense vector reads in the spelling pgvector prints, indices from 1") + func denseVectorParses() throws { + let vector = try #require(VectorLiteral.parse("[1,2.5,-3e-05]")) + #expect(vector.dimensions == 3) + #expect(!vector.isSparse) + #expect(vector.elements.map(\.index) == [1, 2, 3]) + #expect(vector.elements.map(\.text) == ["1", "2.5", "-3e-05"]) + } + + @Test("Spaces pgvector accepts around brackets and elements are read past") + func whitespaceIsAccepted() throws { + let vector = try #require(VectorLiteral.parse(" [ 1 , +2 , .5, 5. ]\n")) + #expect(vector.elements.map(\.text) == ["1", "+2", ".5", "5."]) + } + + @Test("What pgvector refuses is no vector", arguments: [ + "[1,,2]", "[NaN]", "[1, inf]", "", "1,2,3", "[1,2", "[a]", "{1:1,1:2}/3", "{4:1}/3", "{1:1}", "{1:1}/0" + ]) + func invalidTextIsNoVector(text: String) { + #expect(VectorLiteral.parse(text) == nil) + } + + @Test("An empty bracket pair is a vector of no dimensions, so a length check still sees it") + func emptyDenseVector() throws { + let vector = try #require(VectorLiteral.parse("[]")) + #expect(vector.dimensions == 0) + #expect(vector.elements.isEmpty) + } + + @Test("A sparse vector takes its dimensions from after the slash and lists its entries in order") + func sparseVectorParses() throws { + let vector = try #require(VectorLiteral.parse("{3:0.5, 1:2}/5")) + #expect(vector.isSparse) + #expect(vector.dimensions == 5) + #expect(vector.elements == [ + VectorLiteral.Element(index: 1, text: "2"), + VectorLiteral.Element(index: 3, text: "0.5") + ]) + let empty = try #require(VectorLiteral.parse("{}/3")) + #expect(empty.dimensions == 3) + } + + @Test("Copy as JSON gives a dense vector as an array and a sparse one as an object by index") + func jsonText() throws { + let dense = try #require(VectorLiteral.parse("[1,+2,.5,5.,-3e-05]")) + let sparse = try #require(VectorLiteral.parse("{1:2,3:0.5}/5")) + let empty = try #require(VectorLiteral.parse("[]")) + #expect(dense.jsonText == "[1,2,0.5,5,-3e-5]") + #expect(sparse.jsonText == #"{"1":2,"3":0.5}"#) + #expect(empty.jsonText == "[]") + } + + @Test("The grid summary shows the first three elements and the dimension count") + func denseSummary() { + #expect(VectorLiteral.summary(of: "[0.0123,-0.0456,0.0789,0.1,0.2]") == "[0.0123, -0.0456, 0.0789, …] 5 dims") + #expect(VectorLiteral.summary(of: "[1,2,3]") == "[1, 2, 3] 3 dims") + #expect(VectorLiteral.summary(of: "[7]") == "[7] 1 dim") + #expect(VectorLiteral.summary(of: "[]") == "[] 0 dims") + } + + @Test("A 1,536-dimension value summarises without listing it") + func longVectorSummary() { + let literal = "[" + (1...1_536).map { "0.\($0)" }.joined(separator: ",") + "]" + #expect(VectorLiteral.summary(of: literal) == "[0.1, 0.2, 0.3, …] 1536 dims") + } + + @Test("A sparse summary counts the declared dimensions, not the entries") + func sparseSummary() { + #expect(VectorLiteral.summary(of: "{1:2,3:0.5}/1000") == "{1:2, 3:0.5} 1000 dims") + #expect(VectorLiteral.summary(of: "{1:2,3:0.5,7:1,9:4}/10") == "{1:2, 3:0.5, 7:1, …} 10 dims") + #expect(VectorLiteral.summary(of: "{}/3") == "{} 3 dims") + } + + @Test("Text that is not shaped like a vector keeps its own display", arguments: [ + "hello", "[1,,2]", "[[1,2],[3]]", "[a,b]", "{1:2}", "{x:1}/3", "[1,2", "{\"a\":1}" + ]) + func nonVectorHasNoSummary(text: String) { + #expect(VectorLiteral.summary(of: text) == nil) + #expect(!VectorLiteral.isVectorText(text)) + } +} diff --git a/apple/TableProTests/Views/Results/CellInteractionResolverTests.swift b/apple/TableProTests/Views/Results/CellInteractionResolverTests.swift index 07debf610e..4397d2e518 100644 --- a/apple/TableProTests/Views/Results/CellInteractionResolverTests.swift +++ b/apple/TableProTests/Views/Results/CellInteractionResolverTests.swift @@ -410,3 +410,39 @@ struct CellInteractionResolverImageTests { #expect(resolver.resolve(context) == .blocked) } } + +struct CellInteractionResolverVectorTests { + private let resolver = CellInteractionResolver() + private let vector = ColumnType.vector(rawType: "vector(3)") + + @Test("A vector opens the vector viewer, editable where the cell is") + func vectorOpensViewer() { + #expect(resolver.resolve(ContextFactory.make(value: "[1,2,3]", columnType: vector, isTableEditable: true)) == .editVector) + #expect(resolver.resolve(ContextFactory.make(value: "[1,2,3]", columnType: vector)) == .viewVector) + #expect( + resolver.resolve(ContextFactory.make(value: "{1:2}/3", columnType: .vector(rawType: "sparsevec(3)"))) == .viewVector + ) + } + + @Test("NULL and text the viewer cannot list keep the text editor") + func unreadableValueKeepsTextEditor() { + #expect(resolver.resolve(ContextFactory.make(value: nil, columnType: vector, isTableEditable: true)) == .editInline(value: "")) + #expect( + resolver.resolve(ContextFactory.make(value: "oops", columnType: vector, isTableEditable: true)) == .editInline(value: "oops") + ) + } + + @Test("A display format left from the column's text days does not take the viewer away") + func staleFormatIgnored() { + let context = ContextFactory.make( + value: "[1,2,3]", columnType: vector, isTableEditable: true, displayFormatOverride: .json + ) + #expect(resolver.resolve(context) == .editVector) + } + + @Test("A bracketed value in a column that is no vector keeps the text editor") + func textColumnUnchanged() { + let context = ContextFactory.make(value: "[1,2,3]", columnType: .text(rawType: "text"), isTableEditable: true) + #expect(resolver.resolve(context) == .editInline(value: "[1,2,3]")) + } +} diff --git a/apple/TableProTests/Views/Shared/FieldEditorResolverTests.swift b/apple/TableProTests/Views/Shared/FieldEditorResolverTests.swift index 45b9d42160..accc1cfd6d 100644 --- a/apple/TableProTests/Views/Shared/FieldEditorResolverTests.swift +++ b/apple/TableProTests/Views/Shared/FieldEditorResolverTests.swift @@ -702,3 +702,28 @@ struct FieldEditorResolverImageTests { #expect(kind == .arrayElements(element: .json, values: [])) } } + +@MainActor +struct FieldEditorResolverVectorTests { + @Test("A vector the viewer can read gets the vector field ahead of the JSON rule its brackets match") + func vectorField() { + let kind = FieldEditorResolver.resolve( + for: .vector(rawType: "vector(3)"), + isLongText: false, + originalValue: "[1,2,3]" + ) + #expect(kind == .vector) + } + + @Test("NULL and unreadable text keep the text editor, and a format left from the text days is ignored") + func otherwiseText() { + let vector = ColumnType.vector(rawType: "vector(3)") + #expect(FieldEditorResolver.resolve(for: vector, isLongText: false, originalValue: nil) == .singleLine) + #expect(FieldEditorResolver.resolve(for: vector, isLongText: false, originalValue: "oops") == .singleLine) + #expect( + FieldEditorResolver.resolve( + for: vector, isLongText: false, originalValue: "[1,2,3]", displayFormatOverride: .json + ) == .vector + ) + } +} From 0e926a2c818a4ec201a20a02b24907180a2a024c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 04:41:29 +0700 Subject: [PATCH 2/4] fix(datagrid): take a vector's length from its column, not a cast, and keep spaced numbers apart --- .../LibPQPluginConnection+ResultReading.swift | 4 +- .../PgvectorTypes.swift | 3 +- .../Services/Formatting/VectorLiteral.swift | 47 +++++++++++-------- .../Formatting/VectorLiteralTests.swift | 8 +++- 4 files changed, 39 insertions(+), 23 deletions(-) diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+ResultReading.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+ResultReading.swift index 72c0ebe992..d85c715bdf 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+ResultReading.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection+ResultReading.swift @@ -35,7 +35,9 @@ internal extension LibPQPluginConnection { columns.append("column_\(i)") } let oid = UInt32(PQftype(result, Int32(i))) - let modifier = PQfmod(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) columnTypeModifiers.append(modifier) columnTypeNames.append(typeNames.name(for: oid, modifier: modifier)) diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PgvectorTypes.swift b/apple/Plugins/PostgreSQLDriverPlugin/PgvectorTypes.swift index 33aa296b6f..80165b4110 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PgvectorTypes.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PgvectorTypes.swift @@ -29,8 +29,7 @@ enum PgvectorTypes { return names } - // pgvector stores the dimension count as the type modifier, which `PQfmod` reports for a table column - // and an explicit cast and leaves at -1 for any other expression. + // 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("[]") diff --git a/apple/TablePro/Core/Services/Formatting/VectorLiteral.swift b/apple/TablePro/Core/Services/Formatting/VectorLiteral.swift index 4e77cf8e38..ee7a51dff1 100644 --- a/apple/TablePro/Core/Services/Formatting/VectorLiteral.swift +++ b/apple/TablePro/Core/Services/Formatting/VectorLiteral.swift @@ -157,7 +157,7 @@ private struct VectorTextShape { let token = bytes[tokenStart.., sparse: Bool) -> Bool { guard sparse else { return isNumber(token) } guard let colonIndex = token.firstIndex(of: colon) else { return false } - let index = token[token.startIndex..) -> Bool { - var sawDigit = false - for byte in token where !isWhitespace(byte) { - if isDigit(byte) { - sawDigit = true - continue - } - switch byte { - case UInt8(ascii: "."), UInt8(ascii: "-"), UInt8(ascii: "+"), UInt8(ascii: "e"), UInt8(ascii: "E"): - continue - default: - return false - } + let number = trimmed(token) + return number.contains(where: isDigit) && number.allSatisfy(isNumberByte) + } + + private static func display(_ token: ArraySlice, sparse: Bool) -> String { + guard sparse, let colonIndex = token.firstIndex(of: colon) else { return text(trimmed(token)) } + return text(trimmed(token[token.startIndex.. Bool { + switch byte { + case UInt8(ascii: "0")...UInt8(ascii: "9"), UInt8(ascii: "."), UInt8(ascii: "-"), UInt8(ascii: "+"), + UInt8(ascii: "e"), UInt8(ascii: "E"): + return true + default: + return false } - return sawDigit } - private static func display(_ token: ArraySlice) -> String { - String(bytes: token.filter { !isWhitespace($0) }, encoding: .utf8) ?? "" + private static func trimmed(_ token: ArraySlice) -> ArraySlice { + var slice = token.drop(while: isWhitespace) + while let last = slice.last, isWhitespace(last) { slice = slice.dropLast() } + return slice + } + + private static func text(_ bytes: ArraySlice) -> String { + String(bytes: bytes, encoding: .utf8) ?? "" } private static func isWhitespace(_ byte: UInt8) -> Bool { diff --git a/apple/TableProTests/Core/Services/Formatting/VectorLiteralTests.swift b/apple/TableProTests/Core/Services/Formatting/VectorLiteralTests.swift index d392cd8e65..8191e90502 100644 --- a/apple/TableProTests/Core/Services/Formatting/VectorLiteralTests.swift +++ b/apple/TableProTests/Core/Services/Formatting/VectorLiteralTests.swift @@ -81,8 +81,14 @@ struct VectorLiteralTests { #expect(VectorLiteral.summary(of: "{}/3") == "{} 3 dims") } + @Test("Space around an element is read past and never joins two numbers into one") + func spacedElements() { + #expect(VectorLiteral.summary(of: "[ 1 , 2 ]") == "[1, 2] 2 dims") + #expect(VectorLiteral.summary(of: "{ 1 : 0.5 }/3") == "{1:0.5} 3 dims") + } + @Test("Text that is not shaped like a vector keeps its own display", arguments: [ - "hello", "[1,,2]", "[[1,2],[3]]", "[a,b]", "{1:2}", "{x:1}/3", "[1,2", "{\"a\":1}" + "hello", "[1,,2]", "[[1,2],[3]]", "[a,b]", "{1:2}", "{x:1}/3", "[1,2", "{\"a\":1}", "[1 2,3]", "{1 2:3}/5" ]) func nonVectorHasNoSummary(text: String) { #expect(VectorLiteral.summary(of: text) == nil) From a6d66a558fc5d951a1ee63a4042eef081b2bdfa7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 04:46:19 +0700 Subject: [PATCH 3/4] docs(changelog): add the pgvector fragment --- apple/changelog.d/3725.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3725.added.md diff --git a/apple/changelog.d/3725.added.md b/apple/changelog.d/3725.added.md new file mode 100644 index 0000000000..70d50d8dfa --- /dev/null +++ b/apple/changelog.d/3725.added.md @@ -0,0 +1 @@ +pgvector cells show their first elements and dimension count, open in a viewer, and refuse a wrong length on Save. From 91beecf3504711fd77f7af51d4e9d86b01de094b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 08:23:10 +0700 Subject: [PATCH 4/4] chore(l10n): add the vector strings to the app catalog --- .../TablePro/Resources/Localizable.xcstrings | 253 +++++++++++------- 1 file changed, 153 insertions(+), 100 deletions(-) diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index a24f0918ed..7cde5b968e 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -227,9 +227,6 @@ } } } - }, - "~/.ssh/config sets IdentityAgent none for this host, so no SSH agent can sign in. Remove that line, or choose Private Key." : { - }, "—" : { "extractionState" : "stale", @@ -1887,6 +1884,9 @@ } } } + }, + "{host}, {port}, {user} and {database} become the opening connection's values, already quoted. The command also gets them as TABLEPRO_HOST, TABLEPRO_PORT, TABLEPRO_USER and TABLEPRO_DATABASE." : { + }, "**Available commands:**" : { "localizations" : { @@ -3865,9 +3865,6 @@ } } } - }, - "%@ is not a socket." : { - }, "%@ is not a Redis database index." : { "extractionState" : "manual", @@ -3884,6 +3881,9 @@ "%@ is not a serial consistency level. Use SERIAL or LOCAL_SERIAL." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "%@ is not a socket." : { + }, "%@ is not a start position. Use NEWEST, OLDEST, OFFSET, TIME, ANCHOR or TAIL." : { "extractionState" : "manual", @@ -7098,6 +7098,9 @@ } } } + }, + "%1$@ takes %2$lld dimensions, and the value has %3$lld." : { + }, "%1$@ wants to run a query on \"%2$@\"" : { "localizations" : { @@ -10244,6 +10247,9 @@ } } } + }, + "%lld dims" : { + }, "%lld in, %lld out" : { "localizations" : { @@ -12039,6 +12045,19 @@ } } }, + "^[%lld dimension](inflect: true)" : { + + }, + "^[%lld dimension](inflect: true), %lld nonzero" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "^[%1$lld dimension](inflect: true), %2$lld nonzero" + } + } + } + }, "^[%lld line](inflect: true)" : { "localizations" : { "fr" : { @@ -12515,6 +12534,9 @@ } } } + }, + "~/.ssh/config sets IdentityAgent none for this host, so no SSH agent can sign in. Remove that line, or choose Private Key." : { + }, "~/.ssh/id_ed25519.pub" : { "localizations" : { @@ -12943,6 +12965,9 @@ } } } + }, + "1 dim" : { + }, "1 duplicate row" : { "localizations" : { @@ -24155,9 +24180,6 @@ } } } - }, - "And one that will run after this session ends:\n\n%@" : { - }, "ANALYZE (update statistics afterwards)" : { "extractionState" : "manual", @@ -24266,6 +24288,9 @@ }, "And %lld more" : { + }, + "And one that will run after this session ends:\n\n%@" : { + }, "Animations" : { "extractionState" : "stale", @@ -29200,6 +29225,12 @@ } } } + }, + "AWS answered without the Redshift user and password." : { + + }, + "AWS did not issue Redshift credentials: %@" : { + }, "AWS IAM" : { "localizations" : { @@ -29562,12 +29593,6 @@ } } } - }, - "AWS answered without the Redshift user and password." : { - - }, - "AWS did not issue Redshift credentials: %@" : { - }, "Back" : { "localizations" : { @@ -48265,10 +48290,10 @@ "Connects through the socket file on the General tab." : { }, - "Connects to %@. The port names the socket file in the folder." : { + "Connects through the socket on the General tab. PostgreSQL sends no SSL over a socket, which stays on this Mac." : { }, - "Connects through the socket on the General tab. PostgreSQL sends no SSL over a socket, which stays on this Mac." : { + "Connects to %@. The port names the socket file in the folder." : { }, "Consistency" : { @@ -68623,6 +68648,9 @@ } } } + }, + "Edit Vector…" : { + }, "Edit View Definition" : { "localizations" : { @@ -77332,6 +77360,46 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "Facet" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Facette" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패싯" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Faset" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tách biểu đồ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分面" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "分面" + } + } + } + }, "Failed" : { "localizations" : { "fr" : { @@ -83680,46 +83748,6 @@ } } }, - "Facet" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Facette" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "패싯" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Faset" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tách biểu đồ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "分面" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "分面" - } - } - } - }, "Full Access" : { "localizations" : { "fr" : { @@ -91901,6 +91929,9 @@ } } } + }, + "Index" : { + }, "INDEX" : { "extractionState" : "stale", @@ -95038,9 +95069,6 @@ } } } - }, - "It stays on this Mac and never syncs, because it can name a command to run." : { - }, "is empty" : { "localizations" : { @@ -95441,6 +95469,9 @@ } } } + }, + "It stays on this Mac and never syncs, because it can name a command to run." : { + }, "It was changed outside TablePro and no longer loads. Settings > Appearance lists it under Not Loaded with the reason." : { @@ -103427,6 +103458,9 @@ } } } + }, + "Local Network Access Required" : { + }, "Local only" : { "localizations" : { @@ -103587,9 +103621,6 @@ } } } - }, - "Local Network Access Required" : { - }, "Local Secondary Indexes" : { "extractionState" : "manual", @@ -119260,6 +119291,9 @@ } } } + }, + "Not a vector" : { + }, "Not allowed" : { "localizations" : { @@ -140757,8 +140791,8 @@ } }, "Ready after restart" : { - "extractionState" : "stale", "comment" : "A description of a plugin that is ready to use after the app is restarted.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "fr" : { @@ -157141,9 +157175,6 @@ } } } - }, - "show a saved password" : { - }, "Secret Access Key" : { "extractionState" : "manual", @@ -160471,9 +160502,6 @@ } } } - }, - "Set in connections.json." : { - }, "Set Cells to Value" : { "localizations" : { @@ -160574,6 +160602,9 @@ } } } + }, + "Set in connections.json." : { + }, "Set NULL" : { "localizations" : { @@ -161419,6 +161450,9 @@ } } } + }, + "show a saved password" : { + }, "Show All" : { "localizations" : { @@ -163593,122 +163627,122 @@ } } }, - "Showing the first %@ series" : { + "Showing the first %@ facets" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Affichage des %@ premières séries" + "value" : "Affichage des %@ premières facettes" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "처음 %@개 계열을 표시 중" + "value" : "처음 %@개 패싯을 표시 중" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "İlk %@ seri gösteriliyor" + "value" : "İlk %@ faset gösteriliyor" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang hiển thị %@ chuỗi đầu tiên" + "value" : "Đang hiển thị %@ biểu đồ đầu tiên" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "显示前 %@ 个系列" + "value" : "显示前 %@ 个分面" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "顯示前 %@ 個系列" + "value" : "顯示前 %@ 個分面" } } } }, - "Showing the first %1$@ points of %2$@ loaded rows" : { + "Showing the first %@ series" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Affichage des %1$@ premiers points sur %2$@ lignes chargées" + "value" : "Affichage des %@ premières séries" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "불러온 %2$@행 중 처음 %1$@개 지점을 표시 중" + "value" : "처음 %@개 계열을 표시 중" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Yüklenen %2$@ satırın ilk %1$@ noktası gösteriliyor" + "value" : "İlk %@ seri gösteriliyor" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang hiển thị %1$@ điểm đầu tiên trong %2$@ hàng đã tải" + "value" : "Đang hiển thị %@ chuỗi đầu tiên" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "显示已加载 %2$@ 行中的前 %1$@ 个点" + "value" : "显示前 %@ 个系列" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "顯示已載入 %2$@ 列中的前 %1$@ 個點" + "value" : "顯示前 %@ 個系列" } } } }, - "Showing the first %@ facets" : { + "Showing the first %1$@ points of %2$@ loaded rows" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Affichage des %@ premières facettes" + "value" : "Affichage des %1$@ premiers points sur %2$@ lignes chargées" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "처음 %@개 패싯을 표시 중" + "value" : "불러온 %2$@행 중 처음 %1$@개 지점을 표시 중" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "İlk %@ faset gösteriliyor" + "value" : "Yüklenen %2$@ satırın ilk %1$@ noktası gösteriliyor" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang hiển thị %@ biểu đồ đầu tiên" + "value" : "Đang hiển thị %1$@ điểm đầu tiên trong %2$@ hàng đã tải" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "显示前 %@ 个分面" + "value" : "显示已加载 %2$@ 行中的前 %1$@ 个点" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "顯示前 %@ 個分面" + "value" : "顯示已載入 %2$@ 列中的前 %1$@ 個點" } } } @@ -177000,6 +177034,9 @@ } } } + }, + "TablePro does not have Local Network access, so it cannot reach the database at %@." : { + }, "TablePro does not have Local Network access, so it cannot reach the SOCKS proxy at %@." : { @@ -177009,9 +177046,6 @@ }, "TablePro does not have Local Network access, so it cannot reach the SSH server at %@." : { - }, - "TablePro does not have Local Network access, so it cannot reach the database at %@." : { - }, "TablePro does not run a pre-connect script on its own." : { @@ -186492,9 +186526,6 @@ "The Realtime Database sorts only by the column it filters on." : { "extractionState" : "manual", "generatesSymbol" : false - }, - "The Redshift Endpoint applies only when Host is a local port forward or that same endpoint, and \"%@\" is neither." : { - }, "The redirect address is not a local callback, so pairing was refused." : { "localizations" : { @@ -186535,6 +186566,9 @@ } } } + }, + "The Redshift Endpoint applies only when Host is a local port forward or that same endpoint, and \"%@\" is neither." : { + }, "the reference “%@” is inside itself" : { "extractionState" : "manual", @@ -191292,6 +191326,16 @@ } } }, + "This column takes ^[%lld dimension](inflect: true). The value has %lld." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This column takes ^[%1$lld dimension](inflect: true). The value has %2$lld." + } + } + } + }, "This command needs some text." : { "localizations" : { "fr" : { @@ -193987,6 +194031,9 @@ } } } + }, + "This is not a vector value. Write it as [1,2,3]." : { + }, "This is not valid JavaScript." : { "extractionState" : "manual", @@ -207651,6 +207698,9 @@ } } } + }, + "View as Text" : { + }, "View body" : { "localizations" : { @@ -207691,6 +207741,9 @@ } } } + }, + "View Elements" : { + }, "View Elements…" : { "localizations" : { @@ -207951,6 +208004,9 @@ } } } + }, + "View Vector…" : { + }, "View: %@" : { "localizations" : { @@ -211379,9 +211435,6 @@ "write a row of %@" : { "extractionState" : "manual", "generatesSymbol" : false - }, - "{host}, {port}, {user} and {database} become the opening connection's values, already quoted. The command also gets them as TABLEPRO_HOST, TABLEPRO_PORT, TABLEPRO_USER and TABLEPRO_DATABASE." : { - }, "Write and Run SQL" : { "localizations" : {