Skip to content
Closed
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
14 changes: 14 additions & 0 deletions .github/plugin-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,20 @@
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/typesense"
},
"weaviate": {
"target": "WeaviateDriverPlugin",
"bundleName": "WeaviateDriverPlugin",
"bundleId": "com.TablePro.WeaviateDriverPlugin",
"bundled": false,
"displayName": "Weaviate Driver",
"summary": "Weaviate driver over the REST API with a GraphQL console",
"databaseTypeIds": [
"Weaviate"
],
"icon": "weaviate-icon",
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/weaviate"
},
"xlsx": {
"target": "XLSXExport",
"bundleName": "XLSXExport",
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Google Cloud Spanner as a registry plugin over the REST API. (#1226, #2480)
- Weaviate as a registry REST plugin, collections as tables. (#1724)
- TiDB and Databend connection types on the MySQL driver. (#1066, #2514)
- Empty state in the inspector and the assistant for a connection that is not up.
- **Check connections** in Settings > General, including Only when I use the connection. (#2700)
Expand All @@ -20,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Remove Invisible Characters** in the Query menu. (#2717)
- **Show invisible characters** in Settings > Editor. (#2717)
- Warnings in the SQL editor for full-width punctuation, curly quotes and non-ASCII spaces. (#2717)
- Highlight rules that color data grid rows or cells by value. (#2723)
- **Encoding** option for MySQL and MariaDB connections, with **UTF-8 via Latin 1** for databases written through a Latin 1 client. (#2725)

### Changed

Expand All @@ -32,16 +35,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Open Project Folder… in File > Import.
- First-launch tour replaced by a one-page welcome sheet, shown again from Help > Getting Started.
- Beancount connections held at Safe Mode Read-Only. (#2030)
- MySQL sessions on the server's default `utf8mb4` collation.

### Fixed

- Wrong SQLSTATE code in PostgreSQL, Redshift, CockroachDB and PGlite error messages.
- Read-only write explanation never shown on PostgreSQL servers.
- Safe Mode minimum from a configuration profile missing from the toolbar, the Database menu and the connection form. (#2030)
- Stop not ending queries on MySQL and MariaDB servers without TLS.
- Users & Roles failing, Stop not ending queries and sequences listed as tables on TiDB servers opened as MySQL.
- Update release notes show all changes for the offered version, with new features before fixes and properly formatted Markdown. The full changelog is also available from Help and Software Update settings.
- Blank welcome window list when a search matched nothing and a favorite existed.
- Welcome window reading No Connections while a tag filter hid every connection.
- Favorited connection inside a group listed twice on the welcome window.
- Missing red wash on a row deleted together with a new, unsaved row.
- Welcome window tag filter stuck on a tag no connection carries any more, hiding every connection.
- Collapsing every group on the welcome window undone at the next launch.
- Linked Folders and Team Library connections ignoring the welcome window search, with no context menu.
Expand Down Expand Up @@ -121,6 +128,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Line and paragraph separators (U+2028, U+2029) shown as line breaks the database does not see. (#2717)
- Stop on Cloudflare D1, libSQL and Trino cancelling a sidebar read instead of the running query.
- Numeric-looking filter values sent unquoted to text columns when a table first opens or after a foreign key jump.
- Garbled non-Latin text saved to MySQL and MariaDB servers that force a Latin 1 session. (#2725)
- Garbled non-Latin text when restoring a MySQL or MariaDB SQL export through a Latin 1 client. (#2725)
- Curly quotes, € and other Windows-1252 symbols shown as invisible characters after `SET NAMES latin1`.
- `Illegal mix of collations` comparing a column with a user variable on MySQL 8.
- GEOMETRY values from a parameterized MySQL query shown as raw bytes.
- Earlier row's text repeated in later rows of a parameterized MySQL query once a value passed 64 KB.

### Security

Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ To ship one: add the record type or field in CloudKit Console (or `xcrun cktool

**A pooled metadata read assumes a second connection reaches the same database, and an embedded engine breaks that assumption**: `MetadataConnectionPool` builds a whole new driver, so it is only correct when the database lives on a server the driver reconnects to. When the database lives *inside* the driver instance, the pool gets a different database: a second `duckdb_open(":memory:")` is a fresh empty database, and a second `duckdb_open` on the same *file* is a second independent read-write instance that the first never sees (DuckDB's file lock does not conflict within one process). The failure is silent, because an empty catalog is indistinguishable from "no tables", which is why #2108 survived a manual refresh. `supportsConnectionPooling` is the opt-out, and it is read only by `DatabaseManager.canPool`; DuckDB and PGlite set it `false`. SQLite-family engines keep pooling, because multi-connection access to one file is what they are built for. Two rules follow. First, every metadata read goes through `DatabaseManager.withMetadataDriver` so `metadataRoute` can apply the rule; reaching for `MetadataConnectionPool.shared.withDriver` directly bypasses it, which is how routines kept pooling after the sidebar stopped. Second, a capability with no `DriverPlugin` static is curated per type and `buildMetadataSnapshot` must carry it over from the built-in snapshot, or `register(snapshot:forTypeId:)` resets it to the struct default the moment the plugin loads. That is not hypothetical: it silently disabled MongoDB's `authenticationIsDatabaseScoped` (#1970) for every build that had the plugin installed. `registerVariant` already treats the curated entry as authoritative, which is the only reason PGlite's flag ever worked.

**A MySQL session's character set is server state, and the driver sets it rather than asking for it**: `MYSQL_SET_CHARSET_NAME` only puts a collation byte in the handshake, and the server is free to ignore it. `init_connect='SET NAMES latin1'` for any user without `CONNECTION_ADMIN`, MariaDB's `skip-character-set-client-handshake`, and a server with no `utf8mb4` all leave the session latin1 while libmariadb still reports `utf8mb4`. The driver sends UTF-8 either way, so every comment and value it wrote there was stored double-encoded, which is the `メール` of #2725, and a user-variable comparison on MySQL 8 failed with `ERROR 1267` under the handshake's `utf8mb4_general_ci`. So `MariaDBPluginConnection.establishSessionCharacterSet` calls `mysql_set_character_set` on every connect path, falls back to a plain `SET NAMES utf8` (libmariadb itself rewrites `utf8` to `utf8mb3`, which a pre-5.5.3 server does not know), and keeps the server's own session when it refuses both rather than failing the connect: a MySQL-protocol engine that rejects `SET NAMES` must still connect. Two rules follow. Outgoing SQL stays the UTF-8 bytes of the Swift string and never follows a mid-session `SET NAMES`: a legacy dump that declares `SET NAMES latin1` over UTF-8 bytes restores byte-for-byte only because nothing re-encodes it, which is also what the `mysql` client does. And every result cell decodes through `MySQLColumnDecoding` by its field's own `charsetnr`, never as "UTF-8 and hope": a latin1 field reads UTF-8 first and then MySQL's latin1, which is cp1252 plus five C1 pass-through bytes and not Foundation's `.isoLatin1` or `.windowsCP1252`. The Foundation encodings in `MySQLCharacterSet` are a hand transcription of the server's tables; `scripts/check-mysql-charset-decoding.sh` diffs them against a live server, and a charset that disagrees stays out of the table. **UTF-8 via Latin 1** (`mysqlConnectionEncoding`) sets only `character_set_client` to latin1 and repairs the text client-side, so correctly stored text is never read back as `?`, which a latin1 results charset would do and a structure edit would then write back.

**A MongoDB update or delete is anchored on `_id` or it does not run**: `generateDelete` used to fall back to a filter built from the remaining columns, which silently dropped every value it could not stringify (all binary) and then `deleteOne`d the first partial match, so a document with a binary `_id` could delete a different document. Both paths now skip with a logged warning instead, matching what `generateUpdate` already did.

**Redis Cluster routing follows the server's own answer, and the curated table is only a fallback**: `RedisCommandRouting` fetches `COMMAND` once at connect, which supplies key positions on every Redis and, from Redis 7, the `request_policy` / `response_policy` tips that say which commands fan out and how their replies combine. A policy lives on the *subcommand* entry, not the container (`COMMAND INFO config` carries no tips at all; `config|set` is what says `all_nodes`), so the table is keyed `container|sub`. Redis 6 reports no tips, so a parsed reply is merged *over* the curated table rather than replacing it, or `DBSIZE`, `KEYS` and `FLUSHDB` would each go to one shard of a cluster and report success. The curated table is a hand-written list that has to agree with Redis and that nothing at runtime checks, so `scripts/check-redis-command-routing.sh [host] [port]` diffs it against a live Redis 7+; it found 32 disagreements the first time it ran, including a container command hashed on its literal subcommand name and `MSETNX` marked splittable when splitting it breaks the guarantee it exists for. Two rules follow. A container command takes no key of its own, so `OBJECT`, `MEMORY` and `CONFIG` declare no key positions and their keyed subcommands are listed separately, at the index the key sits in the *full* argument list (`OBJECT ENCODING k` puts it at 2, not 1). And an unknown command is routed as keyless rather than by hashing `argv[1]`: a keyless container like `SCRIPT LOAD` answers `+OK` from one node and never sends a `MOVED` to correct the guess.
Expand Down
11 changes: 11 additions & 0 deletions Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ let package = Package(
.library(name: "TableProTrinoCore", targets: ["TableProTrinoCore"]),
.library(name: "TableProGoogleCloud", targets: ["TableProGoogleCloud"]),
.library(name: "TableProSpannerCore", targets: ["TableProSpannerCore"]),
.library(name: "TableProWeaviateCore", targets: ["TableProWeaviateCore"]),
.library(name: "TableProNumberFormatting", targets: ["TableProNumberFormatting"]),
.library(name: "TableProR2SQLCore", targets: ["TableProR2SQLCore"])
],
Expand Down Expand Up @@ -102,6 +103,11 @@ let package = Package(
dependencies: ["TableProGoogleCloud"],
path: "Sources/TableProSpannerCore"
),
.target(
name: "TableProWeaviateCore",
dependencies: [],
path: "Sources/TableProWeaviateCore"
),
.target(
name: "TableProR2SQLCore",
dependencies: [],
Expand Down Expand Up @@ -162,6 +168,11 @@ let package = Package(
dependencies: ["TableProSpannerCore", "TableProGoogleCloud"],
path: "Tests/TableProSpannerCoreTests"
),
.testTarget(
name: "TableProWeaviateCoreTests",
dependencies: ["TableProWeaviateCore"],
path: "Tests/TableProWeaviateCoreTests"
),
.testTarget(
name: "TableProR2SQLCoreTests",
dependencies: ["TableProR2SQLCore"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
public static let trino = DatabaseType(rawValue: "Trino")
public static let kafka = DatabaseType(rawValue: "Kafka")
public static let cloudflareR2SQL = DatabaseType(rawValue: "Cloudflare R2 SQL")
public static let weaviate = DatabaseType(rawValue: "Weaviate")

public static let allKnownTypes: [DatabaseType] = [
.mysql, .mariadb, .tidb, .databend, .postgresql, .sqlite, .redis, .mongodb,
.clickhouse, .mssql, .oracle, .dameng, .duckdb, .cassandra, .redshift,
.etcd, .cloudflareD1, .dynamodb, .bigquery, .spanner, .snowflake, .libsql, .beancount,
.surrealdb, .teradata, .trino, .kafka, .cloudflareR2SQL
.surrealdb, .teradata, .trino, .kafka, .cloudflareR2SQL, .weaviate
]

/// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback
Expand Down Expand Up @@ -79,6 +80,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
case .trino: return "trino-icon"
case .kafka: return "kafka-icon"
case .cloudflareR2SQL: return "cloudflare-r2-sql-icon"
case .weaviate: return "weaviate-icon"
default: return "externaldrive"
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Foundation

public enum WeaviateFieldID {
public static let authMethod = "wvAuthMethod"
public static let apiKey = "wvApiKey"
public static let skipTLSVerify = "wvSkipTLSVerify"
}

public enum WeaviateAuthMethod: String, Sendable, Equatable {
case none
case apiKey
}

public struct WeaviateAuth: Sendable, Equatable {
public let method: WeaviateAuthMethod
public let apiKey: String

public init(method: WeaviateAuthMethod, apiKey: String = "") {
self.method = method
self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
}

public static func parse(fields: [String: String]) -> WeaviateAuth {
let raw = fields[WeaviateFieldID.authMethod] ?? WeaviateAuthMethod.none.rawValue
let method = WeaviateAuthMethod(rawValue: raw) ?? .none
return WeaviateAuth(method: method, apiKey: fields[WeaviateFieldID.apiKey] ?? "")
}

/// A key left in the form after the user switches back to None must not be sent: the form
/// keeps the field's text, and only the method says whether the connection is authenticated.
public var authorizationHeader: String? {
guard method == .apiKey, !apiKey.isEmpty else { return nil }
return "Bearer \(apiKey)"
}
}

public struct WeaviateConnectionSettings: Sendable, Equatable {
public static let defaultPort = 8_080

public let host: String
public let port: Int
public let usesTLS: Bool
public let auth: WeaviateAuth
public let skipTLSVerify: Bool

public init(
host: String,
port: Int,
usesTLS: Bool,
auth: WeaviateAuth,
skipTLSVerify: Bool
) {
self.host = host.trimmingCharacters(in: .whitespacesAndNewlines)
self.port = port
self.usesTLS = usesTLS
self.auth = auth
self.skipTLSVerify = skipTLSVerify
}

public static func parse(
host: String,
port: Int,
usesTLS: Bool,
fields: [String: String]
) throws -> WeaviateConnectionSettings {
let resolvedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedPort = port > 0 ? port : defaultPort
let skipTLS = fields[WeaviateFieldID.skipTLSVerify] == "true"
let settings = WeaviateConnectionSettings(
host: resolvedHost.isEmpty ? "localhost" : resolvedHost,
port: resolvedPort,
usesTLS: usesTLS,
auth: WeaviateAuth.parse(fields: fields),
skipTLSVerify: skipTLS
)
_ = try settings.baseURL()
if settings.auth.method == .apiKey, settings.auth.apiKey.isEmpty {
throw WeaviateError.configuration(String(localized: "Enter a Weaviate API key."))
}
return settings
}

public func baseURL() throws -> URL {
var components = URLComponents()
components.scheme = usesTLS ? "https" : "http"
components.host = host
components.port = port
guard let url = components.url else {
throw WeaviateError.configuration(String(localized: "The host is not valid in a URL."))
}
return url
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation

public enum WeaviateError: Error, LocalizedError, Equatable, Sendable {
case configuration(String)
case notConnected
case transport(String)
case authentication(String)
case api(status: Int, message: String)
case malformedResponse(String)
case cancelled

public var errorDescription: String? {
switch self {
case .configuration(let detail), .transport(let detail), .malformedResponse(let detail):
return detail
case .notConnected:
return String(localized: "Not connected to Weaviate.")
case .authentication(let detail):
return detail
case .api(_, let message):
return message
case .cancelled:
return String(localized: "The request was cancelled.")
}
}

public static func from(status: Int, body: Data) -> WeaviateError {
let message = apiMessage(from: body)
?? String(format: String(localized: "Weaviate returned HTTP %d."), status)
if status == 401 || status == 403 {
return .authentication(message)
}
return .api(status: status, message: message)
}

public static func apiMessage(from body: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: body) else {
let text = String(data: body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return (text?.isEmpty ?? true) ? nil : text
}
if let object = json as? [String: Any] {
if let errors = object["error"] as? [[String: Any]] {
let messages = errors.compactMap { $0["message"] as? String }.filter { !$0.isEmpty }
if !messages.isEmpty {
return messages.joined(separator: "\n")
}
}
if let error = object["error"] as? String, !error.isEmpty {
return error
}
if let message = object["message"] as? String, !message.isEmpty {
return message
}
}
return nil
}
}
Loading
Loading