diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml
index e52bc6d80..077ae3506 100644
--- a/.github/workflows/build-plugin.yml
+++ b/.github/workflows/build-plugin.yml
@@ -208,6 +208,11 @@ jobs:
DISPLAY_NAME="Elasticsearch Driver"; SUMMARY="Elasticsearch driver via the REST API with a Query DSL console"
DB_TYPE_IDS='["Elasticsearch"]'; ICON="elasticsearch-icon"; BUNDLE_NAME="ElasticsearchDriverPlugin"
CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/elasticsearch" ;;
+ surrealdb)
+ TARGET="SurrealDBDriverPlugin"; BUNDLE_ID="com.TablePro.SurrealDBDriverPlugin"
+ DISPLAY_NAME="SurrealDB Driver"; SUMMARY="SurrealDB driver over the HTTP RPC protocol with SurrealQL"
+ DB_TYPE_IDS='["SurrealDB"]'; ICON="surrealdb-icon"; BUNDLE_NAME="SurrealDBDriverPlugin"
+ CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/surrealdb" ;;
bigquery)
TARGET="BigQueryDriverPlugin"; BUNDLE_ID="com.TablePro.BigQueryDriverPlugin"
DISPLAY_NAME="BigQuery Driver"; SUMMARY="Google BigQuery analytics database driver via REST API"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e48918f9c..a906f5c6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- SurrealDB support as a downloadable driver for SurrealDB 2.x and 3.x. Browse namespaces and databases in the sidebar, run SurrealQL, and edit records in the grid. Record links, datetimes, durations, decimals, and UUIDs keep their types, and saving a cell writes only the field you changed. (#1862)
- xAI (Grok) as an AI provider. Paste a key from the xAI Console to use Grok 4.5 or Grok 4.3 on API credits, or click **Sign in with xAI** to use a SuperGrok or X Premium+ subscription with no key. Supports reasoning effort and image attachments.
- Connect to Google Cloud SQL through the Cloud SQL Auth Proxy without starting it yourself. Enable it on a MySQL, PostgreSQL, or SQL Server connection, set the instance connection name, and TablePro runs and stops the proxy with the connection. Supports Application Default Credentials, a service account key, and IAM database authentication, and can download the proxy or use one already on your Mac. (#1728)
- Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, computed balances, and balance assertions project to SQL tables through user-provided `rledger` or Python Beancount, and BQL runs with a `BQL:` prefix when `rledger` is available. (#1474)
diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift
index 37c0e0392..4380e88f0 100644
--- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift
+++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift
@@ -31,11 +31,13 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
public static let cockroachdb = DatabaseType(rawValue: "CockroachDB")
public static let scylladb = DatabaseType(rawValue: "ScyllaDB")
public static let turso = DatabaseType(rawValue: "Turso")
+ public static let surrealdb = DatabaseType(rawValue: "SurrealDB")
public static let allKnownTypes: [DatabaseType] = [
.mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb,
.clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift,
- .etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql, .beancount
+ .etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql, .beancount,
+ .surrealdb
]
/// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback
@@ -60,6 +62,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
case .snowflake: return "snowflake-icon"
case .libsql: return "libsql-icon"
case .beancount: return "beancount-icon"
+ case .surrealdb: return "surrealdb-icon"
default: return "externaldrive"
}
}
diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift
index 042c3a2d4..b4fc01761 100644
--- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift
+++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift
@@ -53,12 +53,13 @@ struct DatabaseTypeTests {
@Test("allKnownTypes contains all expected types")
func allKnownTypesComplete() {
- #expect(DatabaseType.allKnownTypes.count == 19)
+ #expect(DatabaseType.allKnownTypes.count == 20)
#expect(DatabaseType.allKnownTypes.contains(.mysql))
#expect(DatabaseType.allKnownTypes.contains(.bigquery))
#expect(DatabaseType.allKnownTypes.contains(.snowflake))
#expect(DatabaseType.allKnownTypes.contains(.libsql))
#expect(DatabaseType.allKnownTypes.contains(.beancount))
+ #expect(DatabaseType.allKnownTypes.contains(.surrealdb))
}
@Test("Hashable conformance")
diff --git a/Plugins/SurrealDBDriverPlugin/Info.plist b/Plugins/SurrealDBDriverPlugin/Info.plist
new file mode 100644
index 000000000..d13ef0e55
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/Info.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ TableProPluginKitVersion
+ 18
+ TableProProvidesDatabaseTypeIds
+
+ SurrealDB
+
+
+
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealCBOR.swift b/Plugins/SurrealDBDriverPlugin/SurrealCBOR.swift
new file mode 100644
index 000000000..46d05263f
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealCBOR.swift
@@ -0,0 +1,403 @@
+//
+// SurrealCBOR.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+
+public enum SurrealCBORError: Error, Equatable {
+ case truncatedInput
+ case indefiniteLengthUnsupported
+ case unsupportedSimpleValue(UInt8)
+ case invalidUTF8
+ case malformedTag(UInt64)
+ case lengthOverflow
+ case nestingTooDeep
+}
+
+public enum SurrealCBORTag {
+ public static let none: UInt64 = 6
+ public static let table: UInt64 = 7
+ public static let recordId: UInt64 = 8
+ public static let uuidString: UInt64 = 9
+ public static let decimal: UInt64 = 10
+ public static let datetimeString: UInt64 = 0
+ public static let datetimeCompact: UInt64 = 12
+ public static let durationString: UInt64 = 13
+ public static let durationCompact: UInt64 = 14
+ public static let uuidBinary: UInt64 = 37
+ public static let range: UInt64 = 49
+ public static let boundIncluded: UInt64 = 50
+ public static let boundExcluded: UInt64 = 51
+ public static let geometryPoint: UInt64 = 88
+ public static let geometryCollection: UInt64 = 94
+}
+
+public enum SurrealCBOR {
+ private static let maxNestingDepth = 256
+
+ public static func decode(_ data: Data) throws -> SurrealValue {
+ var cursor = Cursor(data)
+ return try decodeItem(&cursor, depth: 0)
+ }
+
+ public static func encode(_ value: SurrealValue) -> Data {
+ var out = Data()
+ encodeItem(value, into: &out)
+ return out
+ }
+
+ // MARK: - Cursor
+
+ private struct Cursor {
+ let bytes: [UInt8]
+ var index: Int = 0
+
+ init(_ data: Data) {
+ self.bytes = [UInt8](data)
+ }
+
+ mutating func nextByte() throws -> UInt8 {
+ guard index < bytes.count else { throw SurrealCBORError.truncatedInput }
+ defer { index += 1 }
+ return bytes[index]
+ }
+
+ mutating func next(_ count: Int) throws -> [UInt8] {
+ guard count >= 0, count <= bytes.count - index else { throw SurrealCBORError.truncatedInput }
+ defer { index += count }
+ return Array(bytes[index..<(index + count)])
+ }
+ }
+
+ // MARK: - Decoding
+
+ private static func readArgument(_ cursor: inout Cursor, info: UInt8) throws -> UInt64 {
+ switch info {
+ case 0...23:
+ return UInt64(info)
+ case 24:
+ return UInt64(try cursor.nextByte())
+ case 25:
+ return try readUInt(&cursor, byteCount: 2)
+ case 26:
+ return try readUInt(&cursor, byteCount: 4)
+ case 27:
+ return try readUInt(&cursor, byteCount: 8)
+ case 31:
+ throw SurrealCBORError.indefiniteLengthUnsupported
+ default:
+ throw SurrealCBORError.truncatedInput
+ }
+ }
+
+ private static func readUInt(_ cursor: inout Cursor, byteCount: Int) throws -> UInt64 {
+ let raw = try cursor.next(byteCount)
+ return raw.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
+ }
+
+ private static func checkedCount(_ argument: UInt64) throws -> Int {
+ guard let count = Int(exactly: argument) else { throw SurrealCBORError.lengthOverflow }
+ return count
+ }
+
+ private static func decodeItem(_ cursor: inout Cursor, depth: Int) throws -> SurrealValue {
+ guard depth < maxNestingDepth else { throw SurrealCBORError.nestingTooDeep }
+ let initial = try cursor.nextByte()
+ let major = initial >> 5
+ let info = initial & 0x1F
+
+ switch major {
+ case 0:
+ let argument = try readArgument(&cursor, info: info)
+ guard let value = Int64(exactly: argument) else { return .double(Double(argument)) }
+ return .int(value)
+ case 1:
+ let argument = try readArgument(&cursor, info: info)
+ guard let magnitude = Int64(exactly: argument) else { return .double(-1 - Double(argument)) }
+ return .int(-1 - magnitude)
+ case 2:
+ let count = try checkedCount(try readArgument(&cursor, info: info))
+ return .bytes(Data(try cursor.next(count)))
+ case 3:
+ let count = try checkedCount(try readArgument(&cursor, info: info))
+ guard let text = String(bytes: try cursor.next(count), encoding: .utf8) else {
+ throw SurrealCBORError.invalidUTF8
+ }
+ return .string(text)
+ case 4:
+ let count = try checkedCount(try readArgument(&cursor, info: info))
+ var items: [SurrealValue] = []
+ items.reserveCapacity(min(count, 1024))
+ for _ in 0.. String {
+ switch value {
+ case let .int(number):
+ return String(number)
+ case let .bool(flag):
+ return String(flag)
+ default:
+ return ""
+ }
+ }
+
+ private static func decodeSimple(_ cursor: inout Cursor, info: UInt8) throws -> SurrealValue {
+ switch info {
+ case 20:
+ return .bool(false)
+ case 21:
+ return .bool(true)
+ case 22:
+ return .null
+ case 23:
+ return .none
+ case 25:
+ return .double(halfToDouble(UInt16(try readUInt(&cursor, byteCount: 2))))
+ case 26:
+ let raw = UInt32(try readUInt(&cursor, byteCount: 4))
+ return .double(Double(Float(bitPattern: raw)))
+ case 27:
+ let raw = try readUInt(&cursor, byteCount: 8)
+ return .double(Double(bitPattern: raw))
+ default:
+ throw SurrealCBORError.unsupportedSimpleValue(info)
+ }
+ }
+
+ private static func halfToDouble(_ raw: UInt16) -> Double {
+ let sign = (raw & 0x8000) != 0 ? -1.0 : 1.0
+ let exponent = Int((raw & 0x7C00) >> 10)
+ let fraction = Double(raw & 0x03FF)
+
+ if exponent == 0 {
+ return sign * pow(2.0, -14) * (fraction / 1024)
+ }
+ if exponent == 0x1F {
+ return fraction == 0 ? sign * Double.infinity : Double.nan
+ }
+ return sign * pow(2.0, Double(exponent - 15)) * (1 + fraction / 1024)
+ }
+
+ private static func interpret(tag: UInt64, inner: SurrealValue) throws -> SurrealValue {
+ switch tag {
+ case SurrealCBORTag.none:
+ return .none
+ case SurrealCBORTag.table:
+ guard let name = inner.stringValue else { throw SurrealCBORError.malformedTag(tag) }
+ return .table(name)
+ case SurrealCBORTag.recordId:
+ return try decodeRecordId(inner, tag: tag)
+ case SurrealCBORTag.uuidString:
+ guard let text = inner.stringValue, let value = UUID(uuidString: text) else {
+ throw SurrealCBORError.malformedTag(tag)
+ }
+ return .uuid(value)
+ case SurrealCBORTag.uuidBinary:
+ return try decodeBinaryUUID(inner, tag: tag)
+ case SurrealCBORTag.decimal:
+ guard let text = inner.stringValue else { throw SurrealCBORError.malformedTag(tag) }
+ return .decimal(text)
+ case SurrealCBORTag.datetimeString:
+ guard let text = inner.stringValue else { throw SurrealCBORError.malformedTag(tag) }
+ return .string(text)
+ case SurrealCBORTag.datetimeCompact:
+ let parts = try secondsAndNanos(inner, tag: tag)
+ return .datetime(seconds: parts.seconds, nanoseconds: parts.nanoseconds)
+ case SurrealCBORTag.durationString:
+ guard let text = inner.stringValue else { throw SurrealCBORError.malformedTag(tag) }
+ return .string(text)
+ case SurrealCBORTag.durationCompact:
+ let parts = try secondsAndNanos(inner, tag: tag)
+ return .duration(seconds: parts.seconds, nanoseconds: parts.nanoseconds)
+ case SurrealCBORTag.range:
+ return try decodeRange(inner, tag: tag)
+ case SurrealCBORTag.boundIncluded, SurrealCBORTag.boundExcluded:
+ return .tagged(tag: tag, value: inner)
+ case SurrealCBORTag.geometryPoint...SurrealCBORTag.geometryCollection:
+ return .tagged(tag: tag, value: inner)
+ default:
+ return inner
+ }
+ }
+
+ private static func decodeRecordId(_ inner: SurrealValue, tag: UInt64) throws -> SurrealValue {
+ guard let parts = inner.arrayValues, parts.count == 2, let table = parts[0].stringValue else {
+ throw SurrealCBORError.malformedTag(tag)
+ }
+ return .recordId(SurrealRecordID(table: table, id: parts[1]))
+ }
+
+ private static func decodeBinaryUUID(_ inner: SurrealValue, tag: UInt64) throws -> SurrealValue {
+ if let text = inner.stringValue, let value = UUID(uuidString: text) {
+ return .uuid(value)
+ }
+ guard case let .bytes(data) = inner, data.count == 16 else {
+ throw SurrealCBORError.malformedTag(tag)
+ }
+ let bytes = [UInt8](data)
+ let uuid = UUID(uuid: (
+ bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
+ bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
+ ))
+ return .uuid(uuid)
+ }
+
+ private static func secondsAndNanos(
+ _ inner: SurrealValue,
+ tag: UInt64
+ ) throws -> (seconds: Int64, nanoseconds: UInt32) {
+ guard let parts = inner.arrayValues else { throw SurrealCBORError.malformedTag(tag) }
+ let seconds = parts.count > 0 ? (parts[0].intValue ?? 0) : 0
+ let nanos = parts.count > 1 ? (parts[1].intValue ?? 0) : 0
+ return (seconds, UInt32(clamping: nanos))
+ }
+
+ private static func decodeRange(_ inner: SurrealValue, tag: UInt64) throws -> SurrealValue {
+ guard let parts = inner.arrayValues, parts.count == 2 else { throw SurrealCBORError.malformedTag(tag) }
+ return .range(from: bound(from: parts[0]), to: bound(from: parts[1]))
+ }
+
+ private static func bound(from value: SurrealValue) -> SurrealBound? {
+ guard case let .tagged(tag, inner) = value else { return nil }
+ switch tag {
+ case SurrealCBORTag.boundIncluded:
+ return SurrealBound(value: inner, isInclusive: true)
+ case SurrealCBORTag.boundExcluded:
+ return SurrealBound(value: inner, isInclusive: false)
+ default:
+ return nil
+ }
+ }
+
+ // MARK: - Encoding
+
+ private static func encodeItem(_ value: SurrealValue, into out: inout Data) {
+ switch value {
+ case .null:
+ out.append(0xF6)
+ case .none:
+ encodeHead(major: 6, argument: SurrealCBORTag.none, into: &out)
+ out.append(0xF6)
+ case let .bool(flag):
+ out.append(flag ? 0xF5 : 0xF4)
+ case let .int(number):
+ encodeInt(number, into: &out)
+ case let .double(number):
+ out.append(0xFB)
+ appendBigEndian(number.bitPattern, byteCount: 8, into: &out)
+ case let .string(text):
+ let utf8 = Array(text.utf8)
+ encodeHead(major: 3, argument: UInt64(utf8.count), into: &out)
+ out.append(contentsOf: utf8)
+ case let .bytes(data):
+ encodeHead(major: 2, argument: UInt64(data.count), into: &out)
+ out.append(data)
+ case let .array(items):
+ encodeHead(major: 4, argument: UInt64(items.count), into: &out)
+ for item in items {
+ encodeItem(item, into: &out)
+ }
+ case let .object(pairs):
+ encodeHead(major: 5, argument: UInt64(pairs.count), into: &out)
+ for pair in pairs {
+ encodeItem(.string(pair.key), into: &out)
+ encodeItem(pair.value, into: &out)
+ }
+ case let .recordId(record):
+ encodeHead(major: 6, argument: SurrealCBORTag.recordId, into: &out)
+ encodeItem(.array([.string(record.table), record.id]), into: &out)
+ case let .table(name):
+ encodeHead(major: 6, argument: SurrealCBORTag.table, into: &out)
+ encodeItem(.string(name), into: &out)
+ case let .uuid(value):
+ encodeHead(major: 6, argument: SurrealCBORTag.uuidString, into: &out)
+ encodeItem(.string(value.uuidString.lowercased()), into: &out)
+ case let .decimal(text):
+ encodeHead(major: 6, argument: SurrealCBORTag.decimal, into: &out)
+ encodeItem(.string(text), into: &out)
+ case let .datetime(seconds, nanoseconds):
+ encodeHead(major: 6, argument: SurrealCBORTag.datetimeCompact, into: &out)
+ encodeItem(.array([.int(seconds), .int(Int64(nanoseconds))]), into: &out)
+ case let .duration(seconds, nanoseconds):
+ encodeHead(major: 6, argument: SurrealCBORTag.durationCompact, into: &out)
+ encodeItem(.array([.int(seconds), .int(Int64(nanoseconds))]), into: &out)
+ case let .tagged(tag, inner):
+ encodeHead(major: 6, argument: tag, into: &out)
+ encodeItem(inner, into: &out)
+ case let .range(from, to):
+ encodeHead(major: 6, argument: SurrealCBORTag.range, into: &out)
+ encodeHead(major: 4, argument: 2, into: &out)
+ encodeBound(from, into: &out)
+ encodeBound(to, into: &out)
+ }
+ }
+
+ private static func encodeBound(_ bound: SurrealBound?, into out: inout Data) {
+ guard let bound else {
+ out.append(0xF6)
+ return
+ }
+ let tag = bound.isInclusive ? SurrealCBORTag.boundIncluded : SurrealCBORTag.boundExcluded
+ encodeHead(major: 6, argument: tag, into: &out)
+ encodeItem(bound.value, into: &out)
+ }
+
+ private static func encodeInt(_ value: Int64, into out: inout Data) {
+ if value >= 0 {
+ encodeHead(major: 0, argument: UInt64(value), into: &out)
+ return
+ }
+ let magnitude = UInt64(bitPattern: Int64(-1) - value)
+ encodeHead(major: 1, argument: magnitude, into: &out)
+ }
+
+ private static func encodeHead(major: UInt8, argument: UInt64, into out: inout Data) {
+ let prefix = major << 5
+ switch argument {
+ case 0...23:
+ out.append(prefix | UInt8(argument))
+ case 24...UInt64(UInt8.max):
+ out.append(prefix | 24)
+ out.append(UInt8(argument))
+ case (UInt64(UInt8.max) + 1)...UInt64(UInt16.max):
+ out.append(prefix | 25)
+ appendBigEndian(argument, byteCount: 2, into: &out)
+ case (UInt64(UInt16.max) + 1)...UInt64(UInt32.max):
+ out.append(prefix | 26)
+ appendBigEndian(argument, byteCount: 4, into: &out)
+ default:
+ out.append(prefix | 27)
+ appendBigEndian(argument, byteCount: 8, into: &out)
+ }
+ }
+
+ private static func appendBigEndian(_ value: UInt64, byteCount: Int, into out: inout Data) {
+ for shift in stride(from: (byteCount - 1) * 8, through: 0, by: -8) {
+ out.append(UInt8((value >> UInt64(shift)) & 0xFF))
+ }
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealCellCoder.swift b/Plugins/SurrealDBDriverPlugin/SurrealCellCoder.swift
new file mode 100644
index 000000000..fe27f11e7
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealCellCoder.swift
@@ -0,0 +1,225 @@
+//
+// SurrealCellCoder.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+public enum SurrealCellCoder {
+ private static let magic: [UInt8] = [0x53, 0x44, 0x42, 0x56]
+
+ public static func parameter(_ value: SurrealValue) -> PluginCellValue {
+ var payload = Data(magic)
+ payload.append(SurrealCBOR.encode(value))
+ return .bytes(payload)
+ }
+
+ public static func value(from cell: PluginCellValue) -> SurrealValue {
+ switch cell {
+ case .null:
+ return .null
+ case let .text(text):
+ return .string(text)
+ case let .bytes(data):
+ guard data.count > magic.count, [UInt8](data.prefix(magic.count)) == magic else {
+ return .bytes(data)
+ }
+ let payload = data.dropFirst(magic.count)
+ guard let decoded = try? SurrealCBOR.decode(Data(payload)) else { return .bytes(data) }
+ return decoded
+ }
+ }
+
+ public static func value(from cell: PluginCellValue, kind: SurrealFieldKind?) -> SurrealValue {
+ switch cell {
+ case .null:
+ return (kind?.isOptional ?? true) ? .none : .null
+ case let .bytes(data):
+ return .bytes(data)
+ case let .text(text):
+ return value(fromText: text, kind: kind)
+ }
+ }
+
+ // MARK: - Text coercion
+
+ private static func value(fromText text: String, kind: SurrealFieldKind?) -> SurrealValue {
+ guard let kind else { return inferred(from: text) }
+
+ if text.isEmpty, kind.base != .string, kind.base != .any {
+ return kind.isOptional ? .none : .null
+ }
+
+ switch kind.base {
+ case .int:
+ guard let number = Int64(text.trimmingCharacters(in: .whitespaces)) else { return .string(text) }
+ return .int(number)
+ case .float, .number:
+ guard let number = Double(text.trimmingCharacters(in: .whitespaces)) else { return .string(text) }
+ return .double(number)
+ case .decimal:
+ return .decimal(text.trimmingCharacters(in: .whitespaces))
+ case .bool:
+ switch text.lowercased().trimmingCharacters(in: .whitespaces) {
+ case "true", "1", "yes":
+ return .bool(true)
+ case "false", "0", "no":
+ return .bool(false)
+ default:
+ return .string(text)
+ }
+ case .datetime:
+ return datetime(from: text) ?? .string(text)
+ case .duration:
+ return duration(from: text) ?? .string(text)
+ case .uuid:
+ guard let value = UUID(uuidString: text.trimmingCharacters(in: .whitespaces)) else { return .string(text) }
+ return .uuid(value)
+ case .record:
+ guard let record = SurrealQL.parseRecordId(text, fallbackTable: kind.recordTable) else {
+ return .string(text)
+ }
+ return .recordId(record)
+ case .object, .array, .set, .geometry:
+ return json(from: text) ?? .string(text)
+ case .string:
+ return .string(text)
+ case .bytes:
+ guard let data = Data(base64Encoded: text) else { return .string(text) }
+ return .bytes(data)
+ case .any:
+ return inferred(from: text)
+ }
+ }
+
+ private static func inferred(from text: String) -> SurrealValue {
+ let trimmed = text.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return .string(text) }
+
+ if trimmed.hasPrefix("{") || trimmed.hasPrefix("[") {
+ if let value = json(from: trimmed) { return value }
+ }
+ if let integer = Int64(trimmed), String(integer) == trimmed {
+ return .int(integer)
+ }
+ switch trimmed.lowercased() {
+ case "true":
+ return .bool(true)
+ case "false":
+ return .bool(false)
+ default:
+ return .string(text)
+ }
+ }
+
+ public static func json(from text: String) -> SurrealValue? {
+ guard let data = text.data(using: .utf8) else { return nil }
+ guard let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else {
+ return nil
+ }
+ return surreal(fromJson: object)
+ }
+
+ private static func surreal(fromJson object: Any) -> SurrealValue {
+ switch object {
+ case is NSNull:
+ return .null
+ case let number as NSNumber:
+ if CFGetTypeID(number) == CFBooleanGetTypeID() {
+ return .bool(number.boolValue)
+ }
+ if let integer = Int64(exactly: number) {
+ return .int(integer)
+ }
+ return .double(number.doubleValue)
+ case let text as String:
+ return .string(text)
+ case let items as [Any]:
+ return .array(items.map(surreal(fromJson:)))
+ case let map as [String: Any]:
+ let pairs = map
+ .sorted { $0.key < $1.key }
+ .map { (key: $0.key, value: surreal(fromJson: $0.value)) }
+ return .object(pairs)
+ default:
+ return .null
+ }
+ }
+
+ public static func datetime(from text: String) -> SurrealValue? {
+ let trimmed = text.trimmingCharacters(in: .whitespaces)
+ let split = splitFraction(trimmed)
+
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ guard let date = formatter.date(from: split.whole) else { return nil }
+
+ return .datetime(seconds: Int64(date.timeIntervalSince1970.rounded()), nanoseconds: split.nanoseconds)
+ }
+
+ private static func splitFraction(_ text: String) -> (whole: String, nanoseconds: UInt32) {
+ guard let dot = text.firstIndex(of: ".") else { return (text, 0) }
+
+ var digits = ""
+ var index = text.index(after: dot)
+ while index < text.endIndex, text[index].isNumber {
+ digits.append(text[index])
+ index = text.index(after: index)
+ }
+ guard !digits.isEmpty else { return (text, 0) }
+
+ let padded = digits.count >= 9
+ ? String(digits.prefix(9))
+ : digits + String(repeating: "0", count: 9 - digits.count)
+ let whole = String(text[text.startIndex.. SurrealValue? {
+ let trimmed = text.trimmingCharacters(in: .whitespaces).lowercased()
+ guard !trimmed.isEmpty else { return nil }
+
+ let units: [(suffix: String, seconds: Int64, nanos: Int64)] = [
+ ("ns", 0, 1),
+ ("µs", 0, 1000),
+ ("us", 0, 1000),
+ ("ms", 0, 1_000_000),
+ ("w", 604_800, 0),
+ ("d", 86_400, 0),
+ ("h", 3600, 0),
+ ("m", 60, 0),
+ ("s", 1, 0)
+ ]
+
+ var totalSeconds: Int64 = 0
+ var totalNanos: Int64 = 0
+ var number = ""
+ var index = trimmed.startIndex
+ var matchedAny = false
+
+ while index < trimmed.endIndex {
+ let character = trimmed[index]
+ if character.isNumber {
+ number.append(character)
+ index = trimmed.index(after: index)
+ continue
+ }
+
+ guard let amount = Int64(number) else { return nil }
+ let remainder = trimmed[index...]
+ guard let unit = units.first(where: { remainder.hasPrefix($0.suffix) }) else { return nil }
+
+ totalSeconds += amount * unit.seconds
+ totalNanos += amount * unit.nanos
+ number = ""
+ matchedAny = true
+ index = trimmed.index(index, offsetBy: unit.suffix.count)
+ }
+
+ guard matchedAny, number.isEmpty else { return nil }
+ totalSeconds += totalNanos / 1_000_000_000
+ totalNanos %= 1_000_000_000
+ return .duration(seconds: totalSeconds, nanoseconds: UInt32(clamping: totalNanos))
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealDBConnectionConfig.swift b/Plugins/SurrealDBDriverPlugin/SurrealDBConnectionConfig.swift
new file mode 100644
index 000000000..3d6fb71d2
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealDBConnectionConfig.swift
@@ -0,0 +1,107 @@
+//
+// SurrealDBConnectionConfig.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+public enum SurrealAuthLevel: String, Sendable, CaseIterable {
+ case root
+ case namespace
+ case database
+ case record
+ case token
+
+ public var usesCredentials: Bool {
+ self != .token
+ }
+}
+
+public struct SurrealDBConnectionConfig: Sendable {
+ public static let authLevelField = "sdbAuthLevel"
+ public static let tokenField = "sdbToken"
+ public static let databaseField = "sdbDatabase"
+ public static let accessField = "sdbAccess"
+ public static let skipTLSVerifyField = "sdbSkipTLSVerify"
+
+ public let host: String
+ public let port: Int
+ public let username: String
+ public let password: String
+ public let namespace: String
+ public let database: String
+ public let authLevel: SurrealAuthLevel
+ public let token: String
+ public let access: String
+ public let useTLS: Bool
+ public let skipTLSVerify: Bool
+
+ public init(config: DriverConnectionConfig) {
+ let fields = config.additionalFields
+ self.host = config.host
+ self.port = config.port > 0 ? config.port : 8000
+ self.username = config.username
+ self.password = config.password
+ self.namespace = config.database
+ self.database = fields[Self.databaseField]?.trimmingCharacters(in: .whitespaces) ?? ""
+ self.authLevel = SurrealAuthLevel(rawValue: fields[Self.authLevelField] ?? "") ?? .root
+ self.token = fields[Self.tokenField] ?? ""
+ self.access = fields[Self.accessField] ?? ""
+ self.useTLS = config.ssl.isEnabled
+ self.skipTLSVerify = fields[Self.skipTLSVerifyField] == "true"
+ || (config.ssl.isEnabled && !config.ssl.verifiesCertificate)
+ }
+
+ public var baseURL: URL? {
+ var components = URLComponents()
+ components.scheme = useTLS ? "https" : "http"
+ components.host = host
+ components.port = port
+ return components.url
+ }
+
+ public func validate() throws {
+ guard !host.trimmingCharacters(in: .whitespaces).isEmpty else {
+ throw SurrealDBError.missingField(String(localized: "Host"))
+ }
+ switch authLevel {
+ case .token:
+ guard !token.trimmingCharacters(in: .whitespaces).isEmpty else {
+ throw SurrealDBError.missingField(String(localized: "Token"))
+ }
+ case .namespace:
+ guard !namespace.isEmpty else {
+ throw SurrealDBError.missingField(String(localized: "Namespace"))
+ }
+ case .database, .record:
+ guard !namespace.isEmpty else {
+ throw SurrealDBError.missingField(String(localized: "Namespace"))
+ }
+ guard !database.isEmpty else {
+ throw SurrealDBError.missingField(String(localized: "Database"))
+ }
+ if authLevel == .record, access.trimmingCharacters(in: .whitespaces).isEmpty {
+ throw SurrealDBError.missingField(String(localized: "Access Method"))
+ }
+ case .root:
+ break
+ }
+ }
+}
+
+public enum SurrealServerVersion {
+ public static func parse(_ raw: String) -> (major: Int, minor: Int)? {
+ let scalars = raw.drop { !$0.isNumber }
+ let parts = scalars.split(separator: ".")
+ guard parts.count >= 2, let major = Int(parts[0]) else { return nil }
+ let minorDigits = parts[1].prefix { $0.isNumber }
+ guard let minor = Int(minorDigits) else { return nil }
+ return (major, minor)
+ }
+
+ public static func isSupported(_ raw: String) -> Bool {
+ guard let version = parse(raw) else { return true }
+ return version.major >= 2
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealDBConnectionFields.swift b/Plugins/SurrealDBDriverPlugin/SurrealDBConnectionFields.swift
new file mode 100644
index 000000000..17225ddfd
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealDBConnectionFields.swift
@@ -0,0 +1,55 @@
+//
+// SurrealDBConnectionFields.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+func surrealDBPluginConnectionFields() -> [ConnectionField] {
+ [
+ ConnectionField(
+ id: SurrealDBConnectionConfig.authLevelField,
+ label: String(localized: "Auth Level"),
+ defaultValue: SurrealAuthLevel.root.rawValue,
+ fieldType: .dropdown(options: [
+ .init(value: "root", label: String(localized: "Root")),
+ .init(value: "namespace", label: String(localized: "Namespace")),
+ .init(value: "database", label: String(localized: "Database")),
+ .init(value: "record", label: String(localized: "Record Access")),
+ .init(value: "token", label: String(localized: "Token")),
+ ]),
+ section: .authentication
+ ),
+ ConnectionField(
+ id: SurrealDBConnectionConfig.tokenField,
+ label: String(localized: "Token"),
+ placeholder: "JWT",
+ fieldType: .secure,
+ section: .authentication,
+ hidesPassword: true,
+ visibleWhen: FieldVisibilityRule(fieldId: SurrealDBConnectionConfig.authLevelField, values: ["token"])
+ ),
+ ConnectionField(
+ id: SurrealDBConnectionConfig.accessField,
+ label: String(localized: "Access Method"),
+ placeholder: "user",
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: SurrealDBConnectionConfig.authLevelField, values: ["record"])
+ ),
+ ConnectionField(
+ id: SurrealDBConnectionConfig.databaseField,
+ label: String(localized: "Database"),
+ placeholder: String(localized: "The database this user belongs to"),
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: SurrealDBConnectionConfig.authLevelField, values: ["database", "record"])
+ ),
+ ConnectionField(
+ id: SurrealDBConnectionConfig.skipTLSVerifyField,
+ label: String(localized: "Skip TLS Verification"),
+ defaultValue: "false",
+ fieldType: .toggle,
+ section: .advanced
+ ),
+ ]
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealDBError.swift b/Plugins/SurrealDBDriverPlugin/SurrealDBError.swift
new file mode 100644
index 000000000..5df2072a6
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealDBError.swift
@@ -0,0 +1,53 @@
+//
+// SurrealDBError.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+public enum SurrealDBError: PluginDriverError {
+ case notConnected
+ case invalidEndpoint(String)
+ case missingField(String)
+ case authenticationFailed(String)
+ case unsupportedServerVersion(String)
+ case requestFailed(status: Int, message: String)
+ case queryFailed(message: String, kind: String?)
+ case decodingFailed(String)
+
+ public var pluginErrorMessage: String {
+ switch self {
+ case .notConnected:
+ return String(localized: "Not connected to SurrealDB.")
+ case let .invalidEndpoint(endpoint):
+ return String(format: String(localized: "Could not build a SurrealDB endpoint from %@."), endpoint)
+ case let .missingField(field):
+ return String(format: String(localized: "%@ is required for the selected authentication level."), field)
+ case let .authenticationFailed(message):
+ return message
+ case let .unsupportedServerVersion(version):
+ return String(
+ format: String(localized: "SurrealDB %@ is not supported. TablePro requires SurrealDB 2.0 or later."),
+ version
+ )
+ case let .requestFailed(_, message):
+ return message
+ case let .queryFailed(message, _):
+ return message
+ case let .decodingFailed(detail):
+ return String(format: String(localized: "Could not read the SurrealDB response: %@"), detail)
+ }
+ }
+
+ public var pluginErrorCode: String? {
+ switch self {
+ case let .requestFailed(status, _):
+ return String(status)
+ case let .queryFailed(_, kind):
+ return kind
+ default:
+ return nil
+ }
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift b/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift
new file mode 100644
index 000000000..3410ce5e4
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift
@@ -0,0 +1,107 @@
+//
+// SurrealDBPlugin.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+final class SurrealDBPlugin: NSObject, TableProPlugin, DriverPlugin {
+ static let pluginName = "SurrealDB Driver"
+ static let pluginVersion = "1.0.0"
+ static let pluginDescription = "SurrealDB driver over the HTTP RPC protocol with SurrealQL"
+ static let capabilities: [PluginCapability] = [.databaseDriver]
+
+ static let databaseTypeId = "SurrealDB"
+ static let databaseDisplayName = "SurrealDB"
+ static let iconName = "surrealdb-icon"
+ static let defaultPort = 8000
+
+ static let connectionMode: ConnectionMode = .network
+ static let isDownloadable = true
+ static let supportsSSH = true
+ static let supportsSSL = true
+ static let supportsImport = false
+ static let supportsSchemaEditing = false
+ static let supportsForeignKeys = false
+ static let supportsTriggers = false
+ static let supportsTriggerEditing = false
+ static let brandColorHex = "#FF00A0"
+ static let urlSchemes: [String] = ["surrealdb"]
+ static let queryLanguageName = "SurrealQL"
+ static let editorLanguage: EditorLanguage = .custom("surrealql")
+ static let parameterStyle: ParameterStyle = .dollar
+
+ static let supportsDatabaseSwitching = true
+ static let supportsSchemaSwitching = true
+ static let databaseGroupingStrategy: GroupingStrategy = .bySchema
+ static let containerEntityName = "Namespace"
+ static let schemaEntityName = "Database"
+ static let defaultSchemaName = ""
+ static let defaultPrimaryKeyColumn: String? = "id"
+ static let immutableColumns: [String] = ["id"]
+ static let supportsDropDatabase = true
+ static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession]
+
+ static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable]
+
+ static let explainVariants: [ExplainVariant] = [
+ ExplainVariant(id: "explain", label: "Explain", sqlPrefix: "EXPLAIN"),
+ ExplainVariant(id: "explainFull", label: "Explain Full", sqlPrefix: "EXPLAIN FULL")
+ ]
+
+ static let columnTypesByCategory: [String: [String]] = [
+ "Integer": ["int"],
+ "Float": ["float", "decimal", "number"],
+ "String": ["string", "uuid"],
+ "Date": ["datetime", "duration"],
+ "Binary": ["bytes"],
+ "Boolean": ["bool"],
+ "Structured": ["object", "array", "set", "geometry", "record", "any"]
+ ]
+
+ static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
+ identifierQuote: "`",
+ keywords: [
+ "SELECT", "FROM", "WHERE", "ORDER", "BY", "GROUP", "ALL", "LIMIT", "START", "FETCH",
+ "SPLIT", "OMIT", "ONLY", "PARALLEL", "TIMEOUT", "EXPLAIN", "WITH", "INDEX",
+ "CREATE", "UPDATE", "UPSERT", "DELETE", "INSERT", "RELATE", "CONTENT", "MERGE",
+ "PATCH", "SET", "RETURN", "BEFORE", "AFTER", "DIFF", "NONE", "NULL", "TRUE", "FALSE",
+ "DEFINE", "REMOVE", "ALTER", "TABLE", "FIELD", "EVENT", "PARAM", "FUNCTION",
+ "NAMESPACE", "DATABASE", "USER", "ACCESS", "ANALYZER", "SCOPE", "TOKEN",
+ "SCHEMAFULL", "SCHEMALESS", "PERMISSIONS", "TYPE", "ASSERT", "DEFAULT", "VALUE",
+ "READONLY", "FLEXIBLE", "UNIQUE", "SEARCH", "MTREE", "HNSW", "COUNT",
+ "USE", "NS", "DB", "LET", "IF", "ELSE", "THEN", "END", "FOR", "IN", "CONTINUE", "BREAK",
+ "BEGIN", "COMMIT", "CANCEL", "TRANSACTION", "INFO", "LIVE", "KILL",
+ "AND", "OR", "NOT", "IS", "CONTAINS", "CONTAINSNOT", "INSIDE", "NOTINSIDE",
+ "OUTSIDE", "INTERSECTS", "ASC", "DESC", "AS", "ON", "IF NOT EXISTS"
+ ],
+ functions: [
+ "count", "math::sum", "math::mean", "math::min", "math::max", "math::abs",
+ "string::concat", "string::contains", "string::starts_with", "string::ends_with",
+ "string::len", "string::lowercase", "string::uppercase", "string::trim",
+ "string::split", "string::join", "string::replace", "string::slice",
+ "time::now", "time::floor", "time::group", "time::unix", "time::format",
+ "array::len", "array::distinct", "array::flatten", "array::group", "array::sort",
+ "array::append", "array::concat", "array::union", "array::first", "array::last",
+ "type::is_number", "type::is_string", "type::table", "type::field", "type::thing",
+ "rand::uuid", "rand::int", "rand::float", "object::keys", "object::values",
+ "meta::id", "meta::tb", "record::id", "record::table"
+ ],
+ dataTypes: [
+ "any", "array", "bool", "bytes", "datetime", "decimal", "duration", "float",
+ "geometry", "int", "number", "object", "option", "record", "set", "string", "uuid"
+ ],
+ regexSyntax: .unsupported,
+ booleanLiteralStyle: .truefalse,
+ likeEscapeStyle: .explicit,
+ paginationStyle: .limit,
+ autoLimitStyle: .limit
+ )
+
+ static let additionalConnectionFields: [ConnectionField] = surrealDBPluginConnectionFields()
+
+ func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
+ SurrealDBPluginDriver(config: config)
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver+Schema.swift b/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver+Schema.swift
new file mode 100644
index 000000000..6a64ae64a
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver+Schema.swift
@@ -0,0 +1,171 @@
+//
+// SurrealDBPluginDriver+Schema.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+extension SurrealDBPluginDriver {
+ private static let schemalessSampleSize = 100
+
+ // MARK: - Namespaces and databases
+
+ func fetchDatabases() async throws -> [String] {
+ if let value = try? await info("INFO FOR ROOT;", scope: SurrealScope(namespace: nil, database: nil)) {
+ let namespaces = SurrealInfoParser.names(from: value, key: "namespaces")
+ if !namespaces.isEmpty { return namespaces }
+ }
+ return settings.namespace.isEmpty ? [] : [settings.namespace]
+ }
+
+ func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata {
+ PluginDatabaseMetadata(name: database)
+ }
+
+ func fetchSchemas() async throws -> [String] {
+ let scope = SurrealScope(namespace: currentNamespace, database: nil)
+ if let value = try? await info("INFO FOR NS;", scope: scope) {
+ let databases = SurrealInfoParser.names(from: value, key: "databases")
+ if !databases.isEmpty { return databases }
+ }
+ return settings.database.isEmpty ? [] : [settings.database]
+ }
+
+ func createDatabase(_ request: PluginCreateDatabaseRequest) async throws {
+ let statement = "DEFINE NAMESPACE " + SurrealQL.quoteIdentifier(request.name) + ";"
+ _ = try await run(statement, scope: SurrealScope(namespace: nil, database: nil))
+ }
+
+ func dropDatabase(name: String) async throws {
+ let statement = "REMOVE NAMESPACE " + SurrealQL.quoteIdentifier(name) + ";"
+ _ = try await run(statement, scope: SurrealScope(namespace: nil, database: nil))
+ }
+
+ // MARK: - Tables
+
+ func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
+ let descriptors = try await tableDescriptors(schema: schema)
+ return descriptors.map {
+ PluginTableInfo(name: $0.name, type: $0.isRelation ? "RELATION" : "TABLE", schema: schema)
+ }
+ }
+
+ func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] {
+ let scope = scope(forSchema: schema)
+ let descriptor = try await descriptor(for: table, schema: schema)
+ let value = try await info(
+ "INFO FOR TABLE " + SurrealQL.quoteIdentifier(table) + " STRUCTURE;",
+ scope: scope
+ )
+
+ var columns = SurrealInfoParser.columns(from: value, isRelation: descriptor?.isRelation ?? false)
+ if columns.count <= 1 || !(descriptor?.isSchemafull ?? false) {
+ columns = try await sampledColumns(table: table, scope: scope, declared: columns)
+ }
+
+ mergeKinds(Self.kinds(from: columns), for: table)
+ return columns
+ }
+
+ func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] {
+ let value = try await info(
+ "INFO FOR TABLE " + SurrealQL.quoteIdentifier(table) + " STRUCTURE;",
+ scope: scope(forSchema: schema)
+ )
+ return SurrealInfoParser.indexes(from: value)
+ }
+
+ func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] {
+ []
+ }
+
+ func fetchTableDDL(table: String, schema: String?) async throws -> String {
+ let scope = scope(forSchema: schema)
+ let value = try await info("INFO FOR TABLE " + SurrealQL.quoteIdentifier(table) + ";", scope: scope)
+ let definitions = SurrealInfoParser.definitions(from: value)
+ let descriptor = try await descriptor(for: table, schema: schema)
+
+ var lines: [String] = []
+ if let descriptor {
+ let mode = descriptor.isSchemafull ? "SCHEMAFULL" : "SCHEMALESS"
+ lines.append("DEFINE TABLE " + SurrealQL.quoteIdentifier(descriptor.name) + " \(mode);")
+ }
+ lines.append(contentsOf: definitions)
+ return lines.joined(separator: "\n")
+ }
+
+ func fetchViewDefinition(view: String, schema: String?) async throws -> String {
+ try await fetchTableDDL(table: view, schema: schema)
+ }
+
+ func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata {
+ let rows = try await count(table: table, schema: schema, filters: [], logicMode: "and")
+ return PluginTableMetadata(tableName: table, rowCount: rows.map(Int64.init))
+ }
+
+ func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? {
+ try await count(table: table, schema: schema, filters: [], logicMode: "and")
+ }
+
+ // MARK: - Helpers
+
+ private func tableDescriptors(schema: String?) async throws -> [SurrealTableDescriptor] {
+ let value = try await info("INFO FOR DB STRUCTURE;", scope: scope(forSchema: schema))
+ return SurrealInfoParser.tables(from: value)
+ }
+
+ private func descriptor(for table: String, schema: String?) async throws -> SurrealTableDescriptor? {
+ try await tableDescriptors(schema: schema).first { $0.name == table }
+ }
+
+ private func sampledColumns(
+ table: String,
+ scope: SurrealScope,
+ declared: [PluginColumnInfo]
+ ) async throws -> [PluginColumnInfo] {
+ let query = SurrealQueryBuilder.sample(table: table, scope: scope, limit: Self.schemalessSampleSize)
+ let results = try await client.query(query, namespace: scope.namespace, database: scope.database)
+ guard let value = results.last?.value, !results.contains(where: { $0.isFailure }) else {
+ return declared
+ }
+
+ let declaredNames = declared.map(\.name)
+ let flattened = SurrealRowFlattener.flatten(value, knownColumns: declaredNames)
+ var columns = declared
+
+ for (index, name) in flattened.columns.enumerated() where !declaredNames.contains(name) {
+ let type = index < flattened.columnTypeNames.count ? flattened.columnTypeNames[index] : "any"
+ columns.append(
+ PluginColumnInfo(
+ name: name,
+ dataType: type,
+ isNullable: true,
+ isPrimaryKey: false
+ )
+ )
+ }
+ return columns
+ }
+
+ private func info(_ statement: String, scope: SurrealScope) async throws -> SurrealValue {
+ let results = try await run(statement, scope: scope)
+ return results.last?.value ?? .none
+ }
+
+ private func run(_ statement: String, scope: SurrealScope) async throws -> [SurrealStatementResult] {
+ let results = try await client.query(statement, namespace: scope.namespace, database: scope.database)
+ if let failure = SurrealRPCClient.firstFailure(results) {
+ throw failure
+ }
+ return results
+ }
+
+ static func kinds(from columns: [PluginColumnInfo]) -> [String: SurrealFieldKind] {
+ var kinds: [String: SurrealFieldKind] = [:]
+ for column in columns {
+ kinds[column.name] = SurrealFieldKind.parse(column.dataType)
+ }
+ return kinds
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver.swift b/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver.swift
new file mode 100644
index 000000000..2b40ecb55
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver.swift
@@ -0,0 +1,364 @@
+//
+// SurrealDBPluginDriver.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+final class SurrealDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
+ let settings: SurrealDBConnectionConfig
+ let client: SurrealRPCClient
+ private let lock = NSLock()
+ private var namespace: String
+ private var database: String?
+ private var kindCache: [String: [String: SurrealFieldKind]] = [:]
+
+ init(config: DriverConnectionConfig) {
+ let settings = SurrealDBConnectionConfig(config: config)
+ self.settings = settings
+ self.client = SurrealRPCClient(config: settings)
+ self.namespace = settings.namespace
+ self.database = settings.database.isEmpty ? nil : settings.database
+ }
+
+ var capabilities: PluginCapabilities {
+ [.parameterizedQueries, .cancelQuery, .truncateTable, .multiSchema]
+ }
+
+ var serverVersion: String? {
+ client.serverVersion
+ }
+
+ var supportsSchemas: Bool { true }
+
+ var currentSchema: String? {
+ lock.withLock { database }
+ }
+
+ var currentNamespace: String {
+ lock.withLock { namespace }
+ }
+
+ var supportsTransactions: Bool { false }
+
+ // MARK: - Lifecycle
+
+ func connect() async throws {
+ try settings.validate()
+ client.start()
+ do {
+ try await client.probeVersion()
+ try await client.authenticate()
+ _ = try await client.query("RETURN 1;", namespace: currentScope().namespace, database: currentScope().database)
+ } catch {
+ client.stop()
+ throw error
+ }
+ }
+
+ func disconnect() {
+ client.stop()
+ lock.withLock { kindCache.removeAll() }
+ }
+
+ func ping() async throws {
+ let scope = currentScope()
+ _ = try await client.query("RETURN 1;", namespace: scope.namespace, database: scope.database)
+ }
+
+ func cancelQuery() throws {
+ client.cancelInFlight()
+ }
+
+ func applyQueryTimeout(_ seconds: Int) async throws {
+ client.applyTimeout(seconds)
+ }
+
+ func beginTransaction() async throws {
+ throw SurrealDBError.queryFailed(
+ message: String(localized: "SurrealDB over HTTP does not support multi-request transactions."),
+ kind: nil
+ )
+ }
+
+ func commitTransaction() async throws {
+ try await beginTransaction()
+ }
+
+ func rollbackTransaction() async throws {
+ try await beginTransaction()
+ }
+
+ // MARK: - Scope
+
+ func currentScope() -> SurrealScope {
+ lock.withLock { SurrealScope(namespace: namespace, database: database) }
+ }
+
+ func scope(forSchema schema: String?) -> SurrealScope {
+ lock.withLock {
+ SurrealScope(namespace: namespace, database: schema ?? database)
+ }
+ }
+
+ func switchDatabase(to database: String) async throws {
+ lock.withLock {
+ namespace = database
+ self.database = nil
+ kindCache.removeAll()
+ }
+ }
+
+ func switchSchema(to schema: String) async throws {
+ lock.withLock {
+ database = schema
+ kindCache.removeAll()
+ }
+ }
+
+ func cachedKinds(for table: String) -> [String: SurrealFieldKind] {
+ lock.withLock { kindCache[table] ?? [:] }
+ }
+
+ func mergeKinds(_ kinds: [String: SurrealFieldKind], for table: String) {
+ guard !kinds.isEmpty else { return }
+ lock.withLock {
+ kindCache[table, default: [:]].merge(kinds) { current, _ in current }
+ }
+ }
+
+ func learnKinds(from value: SurrealValue) {
+ let rows: [SurrealValue]
+ switch value {
+ case let .array(items):
+ rows = items
+ case .object:
+ rows = [value]
+ default:
+ return
+ }
+
+ var learned: [String: [String: SurrealFieldKind]] = [:]
+ for row in rows {
+ guard let pairs = row.objectPairs,
+ case let .recordId(record)? = row[SurrealInfoParser.recordIdColumn] else { continue }
+ for pair in pairs where !SurrealInfoParser.isReservedColumn(pair.key) {
+ guard learned[record.table]?[pair.key] == nil,
+ let kind = SurrealFieldKind.infer(from: pair.value) else { continue }
+ learned[record.table, default: [:]][pair.key] = kind
+ }
+ }
+ for (table, kinds) in learned {
+ mergeKinds(kinds, for: table)
+ }
+ }
+
+ // MARK: - Execution
+
+ func execute(query: String) async throws -> PluginQueryResult {
+ let scope = currentScope()
+ let started = Date()
+ let results = try await client.query(query, namespace: scope.namespace, database: scope.database)
+ if let failure = SurrealRPCClient.firstFailure(results) {
+ throw failure
+ }
+ results.forEach { learnKinds(from: $0.value) }
+ return Self.result(from: results, elapsed: Date().timeIntervalSince(started))
+ }
+
+ func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult {
+ guard !parameters.isEmpty else { return try await execute(query: query) }
+
+ let variables = parameters.enumerated().map { index, cell in
+ (key: "p\(index)", value: SurrealCellCoder.value(from: cell))
+ }
+ let scope = currentScope()
+ let started = Date()
+ let results = try await client.query(
+ query,
+ variables: variables,
+ namespace: scope.namespace,
+ database: scope.database
+ )
+ if let failure = SurrealRPCClient.firstFailure(results) {
+ throw failure
+ }
+ results.forEach { learnKinds(from: $0.value) }
+ return Self.result(from: results, elapsed: Date().timeIntervalSince(started))
+ }
+
+ static func result(from results: [SurrealStatementResult], elapsed: TimeInterval) -> PluginQueryResult {
+ guard let last = results.last else {
+ return PluginQueryResult(
+ columns: [],
+ columnTypeNames: [],
+ rows: [],
+ rowsAffected: 0,
+ executionTime: elapsed
+ )
+ }
+
+ let flattened = SurrealRowFlattener.flatten(last.value)
+ let affected = rowsAffected(last.value)
+ return PluginQueryResult(
+ columns: flattened.columns,
+ columnTypeNames: flattened.columnTypeNames,
+ rows: flattened.rows,
+ rowsAffected: affected,
+ executionTime: elapsed
+ )
+ }
+
+ private static func rowsAffected(_ value: SurrealValue) -> Int {
+ guard case let .array(items) = value else { return 0 }
+ return items.count
+ }
+
+ // MARK: - Query building
+
+ func buildBrowseQuery(
+ table: String,
+ schema: String?,
+ sortColumns: [(columnIndex: Int, ascending: Bool)],
+ columns: [String],
+ limit: Int,
+ offset: Int
+ ) -> String? {
+ SurrealQueryBuilder.browse(
+ table: table,
+ scope: scope(forSchema: schema),
+ sortColumns: Self.sorts(sortColumns, columns: columns),
+ limit: limit,
+ offset: offset
+ )
+ }
+
+ func buildFilteredQuery(
+ table: String,
+ schema: String?,
+ filters: [(column: String, op: String, value: String)],
+ logicMode: String,
+ sortColumns: [(columnIndex: Int, ascending: Bool)],
+ columns: [String],
+ limit: Int,
+ offset: Int
+ ) -> String? {
+ SurrealQueryBuilder.filtered(
+ table: table,
+ scope: scope(forSchema: schema),
+ filters: filters,
+ logicMode: logicMode,
+ sortColumns: Self.sorts(sortColumns, columns: columns),
+ limit: limit,
+ offset: offset
+ )
+ }
+
+ func fetchFilteredRowCount(
+ table: String,
+ filters: [(column: String, op: String, value: String)],
+ logicMode: String
+ ) async throws -> Int? {
+ try await count(table: table, schema: nil, filters: filters, logicMode: logicMode)
+ }
+
+ func count(
+ table: String,
+ schema: String?,
+ filters: [(column: String, op: String, value: String)],
+ logicMode: String
+ ) async throws -> Int? {
+ let scope = scope(forSchema: schema)
+ let query = SurrealQueryBuilder.count(table: table, scope: scope, filters: filters, logicMode: logicMode)
+ let results = try await client.query(query, namespace: scope.namespace, database: scope.database)
+ if let failure = SurrealRPCClient.firstFailure(results) {
+ throw failure
+ }
+ guard let rows = results.last?.value.arrayValues, let total = rows.first?["total"]?.intValue else {
+ return 0
+ }
+ return Int(total)
+ }
+
+ static func sorts(
+ _ sortColumns: [(columnIndex: Int, ascending: Bool)],
+ columns: [String]
+ ) -> [(column: String, ascending: Bool)] {
+ sortColumns.compactMap { sort in
+ guard sort.columnIndex >= 0, sort.columnIndex < columns.count else { return nil }
+ return (column: columns[sort.columnIndex], ascending: sort.ascending)
+ }
+ }
+
+ // MARK: - Mutations
+
+ func generateStatements(
+ table: String,
+ schema: String?,
+ columns: [String],
+ primaryKeyColumns: [String],
+ changes: [PluginRowChange],
+ insertedRowData: [Int: [PluginCellValue]],
+ deletedRowIndices: Set,
+ insertedRowIndices: Set
+ ) -> [(statement: String, parameters: [PluginCellValue])]? {
+ SurrealStatementGenerator.statements(
+ table: table,
+ scope: scope(forSchema: schema),
+ columns: columns,
+ kinds: cachedKinds(for: table),
+ changes: changes,
+ insertedRowData: insertedRowData,
+ deletedRowIndices: deletedRowIndices,
+ insertedRowIndices: insertedRowIndices
+ )
+ }
+
+ // MARK: - Dialect helpers
+
+ func quoteIdentifier(_ name: String) -> String {
+ SurrealQL.quoteIdentifier(name)
+ }
+
+ func escapeStringLiteral(_ value: String) -> String {
+ SurrealQL.escapeStringLiteral(value)
+ }
+
+ func castColumnToText(_ column: String) -> String {
+ " " + SurrealQL.quoteIdentifier(column)
+ }
+
+ func buildExplainQuery(_ sql: String) -> String? {
+ "EXPLAIN " + sql
+ }
+
+ func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? {
+ [SurrealQueryBuilder.compose(
+ scope: scope(forSchema: schema),
+ statement: "DELETE " + SurrealQL.quoteIdentifier(table) + ";"
+ )]
+ }
+
+ func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? {
+ let keyword: String
+ switch objectType.uppercased() {
+ case "NAMESPACE":
+ keyword = "NAMESPACE"
+ case "DATABASE":
+ keyword = "DATABASE"
+ default:
+ keyword = "TABLE"
+ }
+ let statement = "REMOVE \(keyword) " + SurrealQL.quoteIdentifier(name) + ";"
+ guard keyword == "TABLE" else { return statement }
+ return SurrealQueryBuilder.compose(scope: scope(forSchema: schema), statement: statement)
+ }
+
+ func defaultExportQuery(table: String, schema: String?) -> String? {
+ SurrealQueryBuilder.compose(
+ scope: scope(forSchema: schema),
+ statement: "SELECT * FROM " + SurrealQL.quoteIdentifier(table) + ";"
+ )
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealFieldKind.swift b/Plugins/SurrealDBDriverPlugin/SurrealFieldKind.swift
new file mode 100644
index 000000000..810ea714f
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealFieldKind.swift
@@ -0,0 +1,168 @@
+//
+// SurrealFieldKind.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+
+public struct SurrealFieldKind: Equatable, Sendable {
+ public enum Base: String, Equatable, Sendable {
+ case any
+ case bool
+ case bytes
+ case datetime
+ case decimal
+ case duration
+ case float
+ case geometry
+ case int
+ case number
+ case object
+ case record
+ case string
+ case uuid
+ case array
+ case set
+ }
+
+ public let base: Base
+ public let isOptional: Bool
+ public let recordTable: String?
+ public let raw: String
+
+ public init(base: Base, isOptional: Bool, recordTable: String? = nil, raw: String) {
+ self.base = base
+ self.isOptional = isOptional
+ self.recordTable = recordTable
+ self.raw = raw
+ }
+
+ public var isRecordLink: Bool {
+ base == .record
+ }
+
+ public var isStructured: Bool {
+ base == .object || base == .array || base == .set
+ }
+
+ public static func infer(from value: SurrealValue) -> SurrealFieldKind? {
+ let base: Base
+ switch value {
+ case .bool:
+ base = .bool
+ case .int:
+ base = .int
+ case .double:
+ base = .float
+ case .decimal:
+ base = .decimal
+ case .string:
+ base = .string
+ case .bytes:
+ base = .bytes
+ case .uuid:
+ base = .uuid
+ case .datetime:
+ base = .datetime
+ case .duration:
+ base = .duration
+ case .recordId:
+ base = .record
+ case .array:
+ base = .array
+ case .object:
+ base = .object
+ case .tagged, .range:
+ base = .any
+ case .null, .none, .table:
+ return nil
+ }
+ return SurrealFieldKind(base: base, isOptional: false, raw: value.typeName)
+ }
+
+ public static func parse(_ raw: String) -> SurrealFieldKind {
+ let trimmed = raw.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else {
+ return SurrealFieldKind(base: .any, isOptional: true, raw: raw)
+ }
+
+ var body = trimmed
+ var isOptional = false
+
+ if let inner = unwrapGeneric(body, prefix: "option") {
+ body = inner
+ isOptional = true
+ }
+
+ let alternatives = splitUnion(body)
+ let concrete = alternatives.filter { $0.lowercased() != "none" && $0.lowercased() != "null" }
+ if concrete.count != alternatives.count {
+ isOptional = true
+ }
+ body = concrete.first ?? "any"
+
+ return SurrealFieldKind(
+ base: baseKind(of: body),
+ isOptional: isOptional,
+ recordTable: recordTable(of: body),
+ raw: raw
+ )
+ }
+
+ // MARK: - Parsing helpers
+
+ private static func baseKind(of body: String) -> Base {
+ let head = genericHead(body).lowercased()
+ if let base = Base(rawValue: head) {
+ return base
+ }
+ if head.hasPrefix("geometry") {
+ return .geometry
+ }
+ return .any
+ }
+
+ private static func recordTable(of body: String) -> String? {
+ guard let inner = unwrapGeneric(body, prefix: "record") else { return nil }
+ let table = inner.split(separator: "|").first.map { String($0) } ?? inner
+ let trimmed = table.trimmingCharacters(in: .whitespaces)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private static func genericHead(_ body: String) -> String {
+ guard let angle = body.firstIndex(of: "<") else { return body }
+ return String(body[body.startIndex.. String? {
+ let lowered = body.lowercased()
+ guard lowered.hasPrefix(prefix.lowercased() + "<"), body.hasSuffix(">") else { return nil }
+ let start = body.index(body.startIndex, offsetBy: prefix.count + 1)
+ let end = body.index(before: body.endIndex)
+ guard start < end else { return nil }
+ return String(body[start.. [String] {
+ var parts: [String] = []
+ var current = ""
+ var depth = 0
+ for character in body {
+ switch character {
+ case "<":
+ depth += 1
+ current.append(character)
+ case ">":
+ depth -= 1
+ current.append(character)
+ case "|" where depth == 0:
+ parts.append(current.trimmingCharacters(in: .whitespaces))
+ current = ""
+ default:
+ current.append(character)
+ }
+ }
+ parts.append(current.trimmingCharacters(in: .whitespaces))
+ return parts.filter { !$0.isEmpty }
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealInfoParser.swift b/Plugins/SurrealDBDriverPlugin/SurrealInfoParser.swift
new file mode 100644
index 000000000..c3b8eed65
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealInfoParser.swift
@@ -0,0 +1,185 @@
+//
+// SurrealInfoParser.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+public struct SurrealTableDescriptor: Equatable, Sendable {
+ public let name: String
+ public let isSchemafull: Bool
+ public let isRelation: Bool
+
+ public init(name: String, isSchemafull: Bool, isRelation: Bool) {
+ self.name = name
+ self.isSchemafull = isSchemafull
+ self.isRelation = isRelation
+ }
+}
+
+public enum SurrealInfoParser {
+ public static let recordIdColumn = "id"
+ public static let edgeInColumn = "in"
+ public static let edgeOutColumn = "out"
+
+ public static func names(from value: SurrealValue, key: String) -> [String] {
+ guard let entries = value[key]?.objectPairs else { return [] }
+ return entries.map(\.key).sorted()
+ }
+
+ public static func tables(from value: SurrealValue) -> [SurrealTableDescriptor] {
+ if let structured = value["tables"]?.arrayValues {
+ return structured.compactMap(structuredTable).sorted { $0.name < $1.name }
+ }
+ guard let entries = value["tables"]?.objectPairs else { return [] }
+ return entries
+ .map { entry in
+ let definition = entry.value.stringValue ?? ""
+ return SurrealTableDescriptor(
+ name: entry.key,
+ isSchemafull: definition.uppercased().contains("SCHEMAFULL"),
+ isRelation: definition.uppercased().contains("TYPE RELATION")
+ )
+ }
+ .sorted { $0.name < $1.name }
+ }
+
+ public static func columns(from value: SurrealValue, isRelation: Bool) -> [PluginColumnInfo] {
+ let fields = fieldEntries(from: value)
+ var columns: [PluginColumnInfo] = [
+ PluginColumnInfo(
+ name: recordIdColumn,
+ dataType: "record",
+ isNullable: false,
+ isPrimaryKey: true
+ )
+ ]
+ if isRelation {
+ columns.append(edgeColumn(edgeInColumn))
+ columns.append(edgeColumn(edgeOutColumn))
+ }
+
+ for field in fields where !isReservedColumn(field.name) {
+ let kind = SurrealFieldKind.parse(field.kind)
+ columns.append(
+ PluginColumnInfo(
+ name: field.name,
+ dataType: field.kind.isEmpty ? "any" : field.kind,
+ isNullable: kind.isOptional,
+ isPrimaryKey: false
+ )
+ )
+ }
+ return columns
+ }
+
+ public static func indexes(from value: SurrealValue) -> [PluginIndexInfo] {
+ guard let entries = value["indexes"] else { return [] }
+
+ if let structured = entries.arrayValues {
+ return structured.compactMap { entry in
+ guard let name = entry["name"]?.stringValue else { return nil }
+ let definition = (entry["index"]?.stringValue ?? "").uppercased()
+ return PluginIndexInfo(
+ name: name,
+ columns: indexColumns(entry["cols"]),
+ isUnique: definition.contains("UNIQUE"),
+ isPrimary: false,
+ type: definition.isEmpty ? "INDEX" : definition
+ )
+ }
+ }
+
+ guard let pairs = entries.objectPairs else { return [] }
+ return pairs.map { pair in
+ let definition = (pair.value.stringValue ?? "").uppercased()
+ return PluginIndexInfo(
+ name: pair.key,
+ columns: [],
+ isUnique: definition.contains("UNIQUE"),
+ isPrimary: false,
+ type: "INDEX"
+ )
+ }
+ }
+
+ public static func definitions(from value: SurrealValue) -> [String] {
+ var lines: [String] = []
+ for section in ["fields", "indexes", "events", "tables", "lives"] {
+ guard let pairs = value[section]?.objectPairs else { continue }
+ for pair in pairs {
+ guard let text = pair.value.stringValue, !text.isEmpty else { continue }
+ lines.append(text.hasSuffix(";") ? text : text + ";")
+ }
+ }
+ return lines
+ }
+
+ public static func isReservedColumn(_ name: String) -> Bool {
+ name == recordIdColumn || name == edgeInColumn || name == edgeOutColumn
+ }
+
+ public static func isNestedFieldName(_ name: String) -> Bool {
+ name.contains(".") || name.contains("[")
+ }
+
+ // MARK: - Helpers
+
+ private static func edgeColumn(_ name: String) -> PluginColumnInfo {
+ PluginColumnInfo(name: name, dataType: "record", isNullable: false, isPrimaryKey: false)
+ }
+
+ private static func structuredTable(_ entry: SurrealValue) -> SurrealTableDescriptor? {
+ guard let name = entry["name"]?.stringValue else { return nil }
+ let schemafull = entry["schemafull"]?.boolValue ?? entry["full"]?.boolValue ?? false
+ let kind = entry["kind"]?["kind"]?.stringValue?.uppercased()
+ return SurrealTableDescriptor(
+ name: name,
+ isSchemafull: schemafull,
+ isRelation: kind == "RELATION"
+ )
+ }
+
+ private static func fieldEntries(from value: SurrealValue) -> [(name: String, kind: String)] {
+ guard let fields = value["fields"] else { return [] }
+
+ if let structured = fields.arrayValues {
+ return structured.compactMap { field in
+ guard let name = field["name"]?.stringValue, !isNestedFieldName(name) else { return nil }
+ return (name: name, kind: field["kind"]?.stringValue ?? "")
+ }
+ }
+
+ guard let pairs = fields.objectPairs else { return [] }
+ return pairs.compactMap { pair in
+ guard !isNestedFieldName(pair.key) else { return nil }
+ return (name: pair.key, kind: kindFromDefinition(pair.value.stringValue ?? ""))
+ }
+ }
+
+ private static func kindFromDefinition(_ definition: String) -> String {
+ guard let range = definition.range(of: " TYPE ") else { return "" }
+ let tail = definition[range.upperBound...]
+ let terminators = [" PERMISSIONS", " DEFAULT", " VALUE", " ASSERT", " READONLY", " COMMENT"]
+ var end = tail.endIndex
+ for terminator in terminators {
+ if let found = tail.range(of: terminator), found.lowerBound < end {
+ end = found.lowerBound
+ }
+ }
+ return String(tail[tail.startIndex.. [String] {
+ guard let value else { return [] }
+ if let items = value.arrayValues {
+ return items.compactMap { $0.stringValue }
+ }
+ guard let single = value.stringValue else { return [] }
+ return single
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealQL.swift b/Plugins/SurrealDBDriverPlugin/SurrealQL.swift
new file mode 100644
index 000000000..abdd65b18
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealQL.swift
@@ -0,0 +1,144 @@
+//
+// SurrealQL.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+
+public enum SurrealQL {
+ public static func quoteIdentifier(_ name: String) -> String {
+ guard needsQuoting(name) else { return name }
+ return "`" + escapeBackticks(name) + "`"
+ }
+
+ public static func escapeStringLiteral(_ value: String) -> String {
+ var out = ""
+ out.reserveCapacity(value.count)
+ for character in value {
+ switch character {
+ case "\\":
+ out += "\\\\"
+ case "'":
+ out += "\\'"
+ case "\u{00}":
+ continue
+ default:
+ out.append(character)
+ }
+ }
+ return out
+ }
+
+ public static func stringLiteral(_ value: String) -> String {
+ "'" + escapeStringLiteral(value) + "'"
+ }
+
+ public static func recordIdPart(_ id: SurrealValue) -> String {
+ switch id {
+ case let .int(number):
+ return String(number)
+ case let .string(text):
+ return isSimpleIdentifier(text) ? text : "`" + escapeBackticks(text) + "`"
+ case let .uuid(value):
+ return "`" + value.uuidString.lowercased() + "`"
+ default:
+ return "`" + escapeBackticks(id.displayText) + "`"
+ }
+ }
+
+ public static func recordLiteral(_ record: SurrealRecordID) -> String {
+ quoteIdentifier(record.table) + ":" + recordIdPart(record.id)
+ }
+
+ public static func parseRecordId(_ text: String, fallbackTable: String? = nil) -> SurrealRecordID? {
+ let trimmed = text.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return nil }
+
+ guard let separator = separatorIndex(in: trimmed) else {
+ guard let table = fallbackTable else { return nil }
+ return SurrealRecordID(table: table, id: idValue(fromRaw: trimmed))
+ }
+
+ let table = unwrap(String(trimmed[trimmed.startIndex.. SurrealValue {
+ guard !isQuoted(text) else { return .string(unwrap(text)) }
+ if let number = Int64(text), String(number) == text {
+ return .int(number)
+ }
+ return .string(text)
+ }
+
+ private static func isQuoted(_ text: String) -> Bool {
+ guard text.count >= 2 else { return false }
+ if text.hasPrefix("`"), text.hasSuffix("`") { return true }
+ return text.hasPrefix("\u{27E8}") && text.hasSuffix("\u{27E9}")
+ }
+
+ private static func separatorIndex(in text: String) -> String.Index? {
+ var insideBackticks = false
+ var insideAngles = false
+ var index = text.startIndex
+ while index < text.endIndex {
+ let character = text[index]
+ if character == "`" {
+ insideBackticks.toggle()
+ } else if character == "\u{27E8}" {
+ insideAngles = true
+ } else if character == "\u{27E9}" {
+ insideAngles = false
+ } else if character == ":", !insideBackticks, !insideAngles {
+ return index
+ }
+ index = text.index(after: index)
+ }
+ return nil
+ }
+
+ private static func unwrap(_ text: String) -> String {
+ if text.count >= 2, text.hasPrefix("`"), text.hasSuffix("`") {
+ return unescapeBackticks(String(text.dropFirst().dropLast()))
+ }
+ if text.count >= 2, text.hasPrefix("\u{27E8}"), text.hasSuffix("\u{27E9}") {
+ return String(text.dropFirst().dropLast())
+ }
+ return text
+ }
+
+ private static func escapeBackticks(_ text: String) -> String {
+ text.replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "`", with: "\\`")
+ }
+
+ private static func unescapeBackticks(_ text: String) -> String {
+ text.replacingOccurrences(of: "\\`", with: "`")
+ .replacingOccurrences(of: "\\\\", with: "\\")
+ }
+
+ private static func isSimpleIdentifier(_ name: String) -> Bool {
+ guard !name.isEmpty else { return false }
+ guard let first = name.unicodeScalars.first, !CharacterSet.decimalDigits.contains(first) else { return false }
+ return name.unicodeScalars.allSatisfy {
+ CharacterSet.alphanumerics.contains($0) || $0 == "_"
+ }
+ }
+
+ private static func needsQuoting(_ name: String) -> Bool {
+ !isSimpleIdentifier(name) || reservedWords.contains(name.uppercased())
+ }
+
+ private static let reservedWords: Set = [
+ "SELECT", "FROM", "WHERE", "CREATE", "UPDATE", "UPSERT", "DELETE", "RELATE", "INSERT",
+ "DEFINE", "REMOVE", "ALTER", "INFO", "USE", "LET", "RETURN", "BEGIN", "COMMIT", "CANCEL",
+ "TABLE", "FIELD", "INDEX", "NAMESPACE", "DATABASE", "USER", "ACCESS", "EVENT", "PARAM",
+ "AND", "OR", "NOT", "IN", "CONTAINS", "INSIDE", "OUTSIDE", "INTERSECTS",
+ "ORDER", "GROUP", "LIMIT", "START", "FETCH", "SPLIT", "WITH", "EXPLAIN", "TIMEOUT",
+ "NONE", "NULL", "TRUE", "FALSE", "SET", "CONTENT", "MERGE", "PATCH", "LIVE", "KILL"
+ ]
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealQueryBuilder.swift b/Plugins/SurrealDBDriverPlugin/SurrealQueryBuilder.swift
new file mode 100644
index 000000000..eed72857b
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealQueryBuilder.swift
@@ -0,0 +1,216 @@
+//
+// SurrealQueryBuilder.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+
+public struct SurrealScope: Equatable, Sendable {
+ public let namespace: String?
+ public let database: String?
+
+ public init(namespace: String?, database: String?) {
+ self.namespace = namespace?.isEmpty == true ? nil : namespace
+ self.database = database?.isEmpty == true ? nil : database
+ }
+
+ public var useStatement: String? {
+ var clause = ""
+ if let namespace {
+ clause += " NS " + SurrealQL.quoteIdentifier(namespace)
+ }
+ if let database {
+ clause += " DB " + SurrealQL.quoteIdentifier(database)
+ }
+ guard !clause.isEmpty else { return nil }
+ return "USE" + clause + ";"
+ }
+}
+
+public enum SurrealQueryBuilder {
+ public static func browse(
+ table: String,
+ scope: SurrealScope,
+ sortColumns: [(column: String, ascending: Bool)],
+ limit: Int,
+ offset: Int
+ ) -> String {
+ compose(scope: scope, statement: select(table: table, where: nil, sortColumns: sortColumns, limit: limit, offset: offset))
+ }
+
+ public static func filtered(
+ table: String,
+ scope: SurrealScope,
+ filters: [(column: String, op: String, value: String)],
+ logicMode: String,
+ sortColumns: [(column: String, ascending: Bool)],
+ limit: Int,
+ offset: Int
+ ) -> String {
+ let clause = whereClause(filters: filters, logicMode: logicMode)
+ return compose(
+ scope: scope,
+ statement: select(table: table, where: clause, sortColumns: sortColumns, limit: limit, offset: offset)
+ )
+ }
+
+ public static func count(
+ table: String,
+ scope: SurrealScope,
+ filters: [(column: String, op: String, value: String)],
+ logicMode: String
+ ) -> String {
+ var statement = "SELECT count() AS total FROM " + SurrealQL.quoteIdentifier(table)
+ if let clause = whereClause(filters: filters, logicMode: logicMode) {
+ statement += " WHERE " + clause
+ }
+ statement += " GROUP ALL;"
+ return compose(scope: scope, statement: statement)
+ }
+
+ public static func sample(table: String, scope: SurrealScope, limit: Int) -> String {
+ compose(
+ scope: scope,
+ statement: "SELECT * FROM " + SurrealQL.quoteIdentifier(table) + " LIMIT \(max(1, limit));"
+ )
+ }
+
+ public static func compose(scope: SurrealScope, statement: String) -> String {
+ guard let use = scope.useStatement else { return statement }
+ return use + "\n" + statement
+ }
+
+ // MARK: - Statement pieces
+
+ private static func select(
+ table: String,
+ where clause: String?,
+ sortColumns: [(column: String, ascending: Bool)],
+ limit: Int,
+ offset: Int
+ ) -> String {
+ var statement = "SELECT * FROM " + SurrealQL.quoteIdentifier(table)
+ if let clause {
+ statement += " WHERE " + clause
+ }
+ statement += " ORDER BY " + orderBy(sortColumns)
+ statement += " LIMIT \(max(1, limit))"
+ if offset > 0 {
+ statement += " START \(offset)"
+ }
+ return statement + ";"
+ }
+
+ private static func orderBy(_ sortColumns: [(column: String, ascending: Bool)]) -> String {
+ let sorts = sortColumns
+ .filter { !$0.column.isEmpty }
+ .map { SurrealQL.quoteIdentifier($0.column) + ($0.ascending ? " ASC" : " DESC") }
+ guard !sorts.isEmpty else { return "id ASC" }
+ return (sorts + ["id ASC"]).joined(separator: ", ")
+ }
+
+ public static func whereClause(
+ filters: [(column: String, op: String, value: String)],
+ logicMode: String
+ ) -> String? {
+ let conditions = filters.compactMap(condition)
+ guard !conditions.isEmpty else { return nil }
+ let separator = logicMode.lowercased() == "or" ? " OR " : " AND "
+ return conditions.joined(separator: separator)
+ }
+
+ private static func condition(_ filter: (column: String, op: String, value: String)) -> String? {
+ guard !filter.column.isEmpty else { return nil }
+ let column = SurrealQL.quoteIdentifier(filter.column)
+ let op = filter.op.uppercased().trimmingCharacters(in: .whitespaces)
+ let value = filter.value
+
+ switch op {
+ case "IS NULL":
+ return "(\(column) = NONE OR \(column) = NULL)"
+ case "IS NOT NULL":
+ return "(\(column) != NONE AND \(column) != NULL)"
+ case "CONTAINS":
+ return "string::contains( \(column), \(SurrealQL.stringLiteral(value)))"
+ case "NOT CONTAINS":
+ return "!string::contains( \(column), \(SurrealQL.stringLiteral(value)))"
+ case "STARTS WITH":
+ return "string::starts_with( \(column), \(SurrealQL.stringLiteral(value)))"
+ case "ENDS WITH":
+ return "string::ends_with( \(column), \(SurrealQL.stringLiteral(value)))"
+ case "IN":
+ return "\(column) INSIDE \(listLiteral(value))"
+ case "NOT IN":
+ return "\(column) NOTINSIDE \(listLiteral(value))"
+ case "=", "!=", ">", ">=", "<", "<=":
+ return "\(column) \(op) \(literal(value))"
+ case "LIKE":
+ return "string::contains( \(column), \(SurrealQL.stringLiteral(unwrapWildcards(value))))"
+ default:
+ return "\(column) = \(literal(value))"
+ }
+ }
+
+ private static func listLiteral(_ value: String) -> String {
+ let items = value
+ .split(separator: ",")
+ .map { literal($0.trimmingCharacters(in: .whitespaces)) }
+ return "[" + items.joined(separator: ", ") + "]"
+ }
+
+ private static func unwrapWildcards(_ value: String) -> String {
+ var text = value
+ if text.hasPrefix("%") {
+ text.removeFirst()
+ }
+ if text.hasSuffix("%") {
+ text.removeLast()
+ }
+ return text
+ }
+
+ public static func literal(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespaces)
+ let lowered = trimmed.lowercased()
+
+ if lowered == "null" {
+ return "NULL"
+ }
+ if lowered == "none" {
+ return "NONE"
+ }
+ if lowered == "true" || lowered == "false" {
+ return lowered
+ }
+ if isNumeric(trimmed) {
+ return trimmed
+ }
+ if let record = recordLiteral(trimmed) {
+ return record
+ }
+ return SurrealQL.stringLiteral(value)
+ }
+
+ private static func recordLiteral(_ value: String) -> String? {
+ guard !value.contains(" "), looksLikeRecordId(value) else { return nil }
+ guard let record = SurrealQL.parseRecordId(value) else { return nil }
+ return SurrealQL.recordLiteral(record)
+ }
+
+ private static func looksLikeRecordId(_ value: String) -> Bool {
+ guard let colon = value.firstIndex(of: ":"), colon != value.startIndex else { return false }
+ let table = value[value.startIndex.. Bool {
+ guard !value.isEmpty else { return false }
+ if Int64(value) != nil { return true }
+ guard Double(value) != nil else { return false }
+ return value.allSatisfy { $0.isNumber || $0 == "." || $0 == "-" || $0 == "+" || $0 == "e" || $0 == "E" }
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealRPCClient.swift b/Plugins/SurrealDBDriverPlugin/SurrealRPCClient.swift
new file mode 100644
index 000000000..4a91b50d6
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealRPCClient.swift
@@ -0,0 +1,327 @@
+//
+// SurrealRPCClient.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import os
+
+private final class TaskBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var task: URLSessionTask?
+
+ func set(_ task: URLSessionTask) {
+ lock.withLock { self.task = task }
+ }
+
+ func cancel() {
+ lock.withLock { task }?.cancel()
+ }
+}
+
+public struct SurrealStatementResult: Sendable {
+ public let status: String
+ public let value: SurrealValue
+ public let executionTime: TimeInterval
+
+ public var isFailure: Bool {
+ status.uppercased() == "ERR"
+ }
+}
+
+public final class SurrealRPCClient: NSObject, @unchecked Sendable {
+ private static let logger = Logger(subsystem: "com.TablePro", category: "SurrealDBDriver")
+
+ private let config: SurrealDBConnectionConfig
+ private let lock = NSLock()
+ private var session: URLSession?
+ private var bearerToken: String?
+ private var inFlight: [URLSessionTask] = []
+ private var requestId: Int = 0
+ private var timeoutSeconds: Int?
+
+ public private(set) var serverVersion: String?
+
+ public init(config: SurrealDBConnectionConfig) {
+ self.config = config
+ super.init()
+ }
+
+ public func start() {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.httpAdditionalHeaders = [:]
+ configuration.timeoutIntervalForRequest = 60
+ let delegate = config.skipTLSVerify ? self : nil
+ lock.withLock {
+ session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
+ }
+ }
+
+ public func stop() {
+ lock.withLock {
+ session?.invalidateAndCancel()
+ session = nil
+ inFlight.removeAll()
+ bearerToken = nil
+ }
+ }
+
+ public func cancelInFlight() {
+ let tasks = lock.withLock {
+ let snapshot = inFlight
+ inFlight.removeAll()
+ return snapshot
+ }
+ tasks.forEach { $0.cancel() }
+ }
+
+ public func applyTimeout(_ seconds: Int) {
+ lock.withLock { timeoutSeconds = seconds > 0 ? seconds : nil }
+ }
+
+ // MARK: - Authentication
+
+ public func authenticate() async throws {
+ switch config.authLevel {
+ case .token:
+ lock.withLock { bearerToken = config.token.trimmingCharacters(in: .whitespaces) }
+ case .record:
+ lock.withLock { bearerToken = nil }
+ let token = try await signin()
+ lock.withLock { bearerToken = token }
+ case .root, .namespace, .database:
+ lock.withLock { bearerToken = nil }
+ }
+ }
+
+ private func signin() async throws -> String {
+ var payload: [String: String] = ["user": config.username, "pass": config.password]
+ payload["ns"] = config.namespace
+ payload["db"] = config.database
+ payload["ac"] = config.access
+
+ let body = try JSONSerialization.data(withJSONObject: payload)
+ var request = try makeRequest(path: "/signin")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.httpBody = body
+
+ let (data, response) = try await send(request)
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+ throw SurrealDBError.authenticationFailed(Self.plainText(data))
+ }
+ guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let token = object["token"] as? String else {
+ throw SurrealDBError.authenticationFailed(String(localized: "SurrealDB did not return a token."))
+ }
+ return token
+ }
+
+ // MARK: - Queries
+
+ @discardableResult
+ public func query(
+ _ statement: String,
+ variables: [(key: String, value: SurrealValue)] = [],
+ namespace: String?,
+ database: String?
+ ) async throws -> [SurrealStatementResult] {
+ var params: [SurrealValue] = [.string(statement)]
+ if !variables.isEmpty {
+ params.append(.object(variables))
+ }
+
+ let id = lock.withLock { () -> Int in
+ requestId += 1
+ return requestId
+ }
+
+ let envelope = SurrealValue.object([
+ (key: "id", value: .int(Int64(id))),
+ (key: "method", value: .string("query")),
+ (key: "params", value: .array(params))
+ ])
+
+ var request = try makeRequest(path: "/rpc")
+ request.setValue("application/cbor", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/cbor", forHTTPHeaderField: "Accept")
+ applyScopeHeaders(&request, namespace: namespace, database: database)
+ request.httpBody = SurrealCBOR.encode(envelope)
+
+ let (data, response) = try await send(request)
+ return try decode(data: data, response: response)
+ }
+
+ public func probeVersion() async throws {
+ var request = try makeRequest(path: "/version", authenticated: false)
+ request.httpMethod = "GET"
+ let (data, response) = try await send(request)
+
+ let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "surreal-version")
+ let raw = header ?? Self.plainText(data)
+ let version = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ lock.withLock { serverVersion = version }
+
+ guard SurrealServerVersion.isSupported(version) else {
+ throw SurrealDBError.unsupportedServerVersion(version)
+ }
+ }
+
+ // MARK: - Request plumbing
+
+ private func makeRequest(path: String, authenticated: Bool = true) throws -> URLRequest {
+ guard let base = config.baseURL, let url = URL(string: path, relativeTo: base) else {
+ throw SurrealDBError.invalidEndpoint(config.host)
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ if let timeout = lock.withLock({ timeoutSeconds }) {
+ request.timeoutInterval = TimeInterval(timeout)
+ }
+ guard authenticated else { return request }
+
+ let token = lock.withLock { bearerToken }
+ if let token, !token.isEmpty {
+ request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
+ return request
+ }
+ if config.authLevel.usesCredentials, !config.username.isEmpty {
+ let raw = config.username + ":" + config.password
+ let encoded = Data(raw.utf8).base64EncodedString()
+ request.setValue("Basic " + encoded, forHTTPHeaderField: "Authorization")
+ }
+ return request
+ }
+
+ private func applyScopeHeaders(_ request: inout URLRequest, namespace: String?, database: String?) {
+ if let namespace, !namespace.isEmpty {
+ request.setValue(namespace, forHTTPHeaderField: "surreal-ns")
+ }
+ if let database, !database.isEmpty {
+ request.setValue(database, forHTTPHeaderField: "surreal-db")
+ }
+ switch config.authLevel {
+ case .namespace:
+ request.setValue(config.namespace, forHTTPHeaderField: "surreal-auth-ns")
+ case .database, .record:
+ request.setValue(config.namespace, forHTTPHeaderField: "surreal-auth-ns")
+ request.setValue(config.database, forHTTPHeaderField: "surreal-auth-db")
+ case .root, .token:
+ break
+ }
+ }
+
+ private func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
+ let box = TaskBox()
+ return try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ lock.withLock {
+ guard let session else {
+ continuation.resume(throwing: SurrealDBError.notConnected)
+ return
+ }
+ let task = session.dataTask(with: request) { [weak self] data, response, error in
+ self?.finish()
+ if let error {
+ continuation.resume(throwing: error)
+ return
+ }
+ guard let data, let response else {
+ continuation.resume(throwing: SurrealDBError.decodingFailed(String(localized: "Empty response")))
+ return
+ }
+ continuation.resume(returning: (data, response))
+ }
+ inFlight.append(task)
+ box.set(task)
+ task.resume()
+ }
+ }
+ } onCancel: {
+ box.cancel()
+ }
+ }
+
+ private func finish() {
+ lock.withLock {
+ inFlight.removeAll { $0.state == .completed || $0.state == .canceling }
+ }
+ }
+
+ // MARK: - Decoding
+
+ private func decode(data: Data, response: URLResponse) throws -> [SurrealStatementResult] {
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
+
+ if status == 401 || status == 403 {
+ throw SurrealDBError.authenticationFailed(Self.plainText(data))
+ }
+
+ guard let envelope = try? SurrealCBOR.decode(data) else {
+ guard (200..<300).contains(status) else {
+ throw SurrealDBError.requestFailed(status: status, message: Self.plainText(data))
+ }
+ throw SurrealDBError.decodingFailed(Self.plainText(data))
+ }
+
+ if let error = envelope["error"] {
+ let message = error["message"]?.stringValue ?? String(localized: "SurrealDB rejected the request.")
+ throw SurrealDBError.queryFailed(message: message, kind: error["kind"]?.stringValue)
+ }
+
+ guard (200..<300).contains(status) else {
+ throw SurrealDBError.requestFailed(status: status, message: Self.plainText(data))
+ }
+
+ guard let results = envelope["result"]?.arrayValues else {
+ return [SurrealStatementResult(status: "OK", value: envelope["result"] ?? .none, executionTime: 0)]
+ }
+
+ return results.map { entry in
+ SurrealStatementResult(
+ status: entry["status"]?.stringValue ?? "OK",
+ value: entry["result"] ?? .none,
+ executionTime: Self.duration(entry["time"]?.stringValue)
+ )
+ }
+ }
+
+ public static func firstFailure(_ results: [SurrealStatementResult]) -> SurrealDBError? {
+ guard let failure = results.first(where: { $0.isFailure }) else { return nil }
+ let message = failure.value.stringValue ?? String(localized: "The SurrealDB statement failed.")
+ return SurrealDBError.queryFailed(message: message, kind: nil)
+ }
+
+ private static func plainText(_ data: Data) -> String {
+ let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let text, !text.isEmpty else { return String(localized: "SurrealDB returned no details.") }
+ return text
+ }
+
+ private static func duration(_ raw: String?) -> TimeInterval {
+ guard let raw else { return 0 }
+ let units: [(String, Double)] = [("ns", 1e-9), ("µs", 1e-6), ("us", 1e-6), ("ms", 1e-3), ("s", 1)]
+ for (suffix, scale) in units where raw.hasSuffix(suffix) {
+ let number = raw.dropLast(suffix.count)
+ guard let value = Double(number) else { return 0 }
+ return value * scale
+ }
+ return 0
+ }
+}
+
+extension SurrealRPCClient: URLSessionDelegate {
+ public func urlSession(
+ _ session: URLSession,
+ didReceive challenge: URLAuthenticationChallenge,
+ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
+ ) {
+ guard config.skipTLSVerify,
+ challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
+ let trust = challenge.protectionSpace.serverTrust else {
+ completionHandler(.performDefaultHandling, nil)
+ return
+ }
+ completionHandler(.useCredential, URLCredential(trust: trust))
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealRowFlattener.swift b/Plugins/SurrealDBDriverPlugin/SurrealRowFlattener.swift
new file mode 100644
index 000000000..d27478763
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealRowFlattener.swift
@@ -0,0 +1,112 @@
+//
+// SurrealRowFlattener.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+public struct SurrealFlattenedRows: Equatable, Sendable {
+ public let columns: [String]
+ public let columnTypeNames: [String]
+ public let rows: [[PluginCellValue]]
+
+ public init(columns: [String], columnTypeNames: [String], rows: [[PluginCellValue]]) {
+ self.columns = columns
+ self.columnTypeNames = columnTypeNames
+ self.rows = rows
+ }
+}
+
+public enum SurrealRowFlattener {
+ public static func flatten(_ value: SurrealValue, knownColumns: [String] = []) -> SurrealFlattenedRows {
+ let records = normalize(value)
+ guard !records.isEmpty || !knownColumns.isEmpty else {
+ return SurrealFlattenedRows(columns: [], columnTypeNames: [], rows: [])
+ }
+
+ if records.allSatisfy({ $0.objectPairs != nil }) {
+ let columns = orderColumns(unionColumns(records, seeded: knownColumns))
+ let rows = records.map { record in
+ columns.map { cell(record[$0] ?? .none) }
+ }
+ return SurrealFlattenedRows(
+ columns: columns,
+ columnTypeNames: typeNames(for: columns, in: records),
+ rows: rows
+ )
+ }
+
+ let column = "result"
+ return SurrealFlattenedRows(
+ columns: [column],
+ columnTypeNames: [records.first?.typeName ?? "any"],
+ rows: records.map { [cell($0)] }
+ )
+ }
+
+ public static func cell(_ value: SurrealValue) -> PluginCellValue {
+ switch value {
+ case .null, .none:
+ return .null
+ case let .bytes(data):
+ return .bytes(data)
+ default:
+ return .text(value.displayText)
+ }
+ }
+
+ // MARK: - Helpers
+
+ private static func normalize(_ value: SurrealValue) -> [SurrealValue] {
+ switch value {
+ case let .array(items):
+ return items
+ case .null, .none:
+ return []
+ default:
+ return [value]
+ }
+ }
+
+ private static func unionColumns(_ records: [SurrealValue], seeded: [String]) -> [String] {
+ var seen = Set()
+ var ordered: [String] = []
+
+ for column in seeded where !seen.contains(column) {
+ seen.insert(column)
+ ordered.append(column)
+ }
+ for record in records {
+ guard let pairs = record.objectPairs else { continue }
+ for pair in pairs where !seen.contains(pair.key) {
+ seen.insert(pair.key)
+ ordered.append(pair.key)
+ }
+ }
+ return ordered
+ }
+
+ private static func orderColumns(_ columns: [String]) -> [String] {
+ var ordered: [String] = []
+ for pinned in [
+ SurrealInfoParser.recordIdColumn,
+ SurrealInfoParser.edgeInColumn,
+ SurrealInfoParser.edgeOutColumn
+ ] where columns.contains(pinned) {
+ ordered.append(pinned)
+ }
+ ordered.append(contentsOf: columns.filter { !ordered.contains($0) })
+ return ordered
+ }
+
+ private static func typeNames(for columns: [String], in records: [SurrealValue]) -> [String] {
+ columns.map { column in
+ for record in records {
+ guard let value = record[column], !value.isAbsent else { continue }
+ return value.typeName
+ }
+ return "any"
+ }
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealStatementGenerator.swift b/Plugins/SurrealDBDriverPlugin/SurrealStatementGenerator.swift
new file mode 100644
index 000000000..a019027eb
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealStatementGenerator.swift
@@ -0,0 +1,145 @@
+//
+// SurrealStatementGenerator.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+public typealias SurrealStatement = (statement: String, parameters: [PluginCellValue])
+
+public enum SurrealStatementGenerator {
+ static let autoIdMarker = "__DEFAULT__"
+
+ static func isAutoDefault(_ value: PluginCellValue) -> Bool {
+ guard case let .text(text) = value else { return false }
+ return text.trimmingCharacters(in: .whitespaces) == autoIdMarker
+ }
+
+ public static func statements(
+ table: String,
+ scope: SurrealScope,
+ columns: [String],
+ kinds: [String: SurrealFieldKind],
+ changes: [PluginRowChange],
+ insertedRowData: [Int: [PluginCellValue]],
+ deletedRowIndices: Set,
+ insertedRowIndices: Set
+ ) -> [SurrealStatement] {
+ var statements: [SurrealStatement] = []
+
+ for change in changes where change.type == .update && !insertedRowIndices.contains(change.rowIndex) {
+ guard let statement = update(table: table, scope: scope, columns: columns, kinds: kinds, change: change) else {
+ continue
+ }
+ statements.append(statement)
+ }
+
+ for index in insertedRowIndices.sorted() {
+ guard let values = insertedRowData[index] else { continue }
+ guard let statement = insert(table: table, scope: scope, columns: columns, kinds: kinds, values: values) else {
+ continue
+ }
+ statements.append(statement)
+ }
+
+ for change in changes where change.type == .delete || deletedRowIndices.contains(change.rowIndex) {
+ guard !insertedRowIndices.contains(change.rowIndex) else { continue }
+ guard let statement = delete(table: table, scope: scope, columns: columns, change: change) else { continue }
+ statements.append(statement)
+ }
+
+ return statements
+ }
+
+ // MARK: - Statements
+
+ private static func update(
+ table: String,
+ scope: SurrealScope,
+ columns: [String],
+ kinds: [String: SurrealFieldKind],
+ change: PluginRowChange
+ ) -> SurrealStatement? {
+ guard let record = recordId(table: table, columns: columns, originalRow: change.originalRow) else { return nil }
+ let editable = change.cellChanges.filter {
+ !SurrealInfoParser.isReservedColumn($0.columnName) && !Self.isAutoDefault($0.newValue)
+ }
+ guard !editable.isEmpty else { return nil }
+
+ var parameters: [PluginCellValue] = [SurrealCellCoder.parameter(.recordId(record))]
+ var assignments: [String] = []
+
+ for cell in editable {
+ let value = SurrealCellCoder.value(from: cell.newValue, kind: kinds[cell.columnName])
+ parameters.append(SurrealCellCoder.parameter(value))
+ assignments.append(SurrealQL.quoteIdentifier(cell.columnName) + " = $p\(parameters.count - 1)")
+ }
+
+ let statement = "UPDATE $p0 SET " + assignments.joined(separator: ", ") + ";"
+ return (SurrealQueryBuilder.compose(scope: scope, statement: statement), parameters)
+ }
+
+ private static func insert(
+ table: String,
+ scope: SurrealScope,
+ columns: [String],
+ kinds: [String: SurrealFieldKind],
+ values: [PluginCellValue]
+ ) -> SurrealStatement? {
+ var parameters: [PluginCellValue] = []
+ var assignments: [String] = []
+ var target = SurrealQL.quoteIdentifier(table)
+
+ for (index, column) in columns.enumerated() {
+ guard index < values.count else { continue }
+ let cell = values[index]
+
+ if column == SurrealInfoParser.recordIdColumn {
+ guard case let .text(text) = cell else { continue }
+ let trimmed = text.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty, trimmed != Self.autoIdMarker else { continue }
+ guard let record = SurrealQL.parseRecordId(text, fallbackTable: table) else { continue }
+ parameters.append(SurrealCellCoder.parameter(.recordId(record)))
+ target = "$p\(parameters.count - 1)"
+ continue
+ }
+
+ if case .null = cell { continue }
+ if Self.isAutoDefault(cell) { continue }
+ let value = SurrealCellCoder.value(from: cell, kind: kinds[column])
+ parameters.append(SurrealCellCoder.parameter(value))
+ assignments.append(SurrealQL.quoteIdentifier(column) + " = $p\(parameters.count - 1)")
+ }
+
+ let statement = assignments.isEmpty
+ ? "CREATE \(target);"
+ : "CREATE \(target) SET " + assignments.joined(separator: ", ") + ";"
+ return (SurrealQueryBuilder.compose(scope: scope, statement: statement), parameters)
+ }
+
+ private static func delete(
+ table: String,
+ scope: SurrealScope,
+ columns: [String],
+ change: PluginRowChange
+ ) -> SurrealStatement? {
+ guard let record = recordId(table: table, columns: columns, originalRow: change.originalRow) else { return nil }
+ let parameters = [SurrealCellCoder.parameter(.recordId(record))]
+ return (SurrealQueryBuilder.compose(scope: scope, statement: "DELETE $p0;"), parameters)
+ }
+
+ // MARK: - Helpers
+
+ private static func recordId(
+ table: String,
+ columns: [String],
+ originalRow: [PluginCellValue]?
+ ) -> SurrealRecordID? {
+ guard let originalRow,
+ let index = columns.firstIndex(of: SurrealInfoParser.recordIdColumn),
+ index < originalRow.count,
+ case let .text(text) = originalRow[index] else { return nil }
+ return SurrealQL.parseRecordId(text, fallbackTable: table)
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealValue+Display.swift b/Plugins/SurrealDBDriverPlugin/SurrealValue+Display.swift
new file mode 100644
index 000000000..295929a05
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealValue+Display.swift
@@ -0,0 +1,258 @@
+//
+// SurrealValue+Display.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+
+public extension SurrealValue {
+ static let maxSerializedLength = 10_000
+
+ var displayText: String {
+ switch self {
+ case .null, .none:
+ return ""
+ case let .bool(flag):
+ return flag ? "true" : "false"
+ case let .int(number):
+ return String(number)
+ case let .double(number):
+ return Self.formatDouble(number)
+ case let .string(text):
+ return text
+ case let .bytes(data):
+ return data.base64EncodedString()
+ case .array, .object:
+ return jsonText
+ case let .recordId(record):
+ return record.literal
+ case let .table(name):
+ return name
+ case let .uuid(value):
+ return value.uuidString.lowercased()
+ case let .decimal(text):
+ return text
+ case let .datetime(seconds, nanoseconds):
+ return Self.formatDatetime(seconds: seconds, nanoseconds: nanoseconds)
+ case let .duration(seconds, nanoseconds):
+ return Self.formatDuration(seconds: seconds, nanoseconds: nanoseconds)
+ case .tagged:
+ return jsonText
+ case let .range(from, to):
+ return Self.formatRange(from: from, to: to)
+ }
+ }
+
+ var jsonText: String {
+ let text = Self.jsonFragment(self)
+ guard (text as NSString).length > Self.maxSerializedLength else { return text }
+ return String(text.prefix(Self.maxSerializedLength)) + "..."
+ }
+
+ var typeName: String {
+ switch self {
+ case .null:
+ return "null"
+ case .none:
+ return "none"
+ case .bool:
+ return "bool"
+ case .int:
+ return "int"
+ case .double:
+ return "float"
+ case .string:
+ return "string"
+ case .bytes:
+ return "bytes"
+ case .array:
+ return "array"
+ case .object:
+ return "object"
+ case .recordId:
+ return "record"
+ case .table:
+ return "table"
+ case .uuid:
+ return "uuid"
+ case .decimal:
+ return "decimal"
+ case .datetime:
+ return "datetime"
+ case .duration:
+ return "duration"
+ case .tagged(let tag, _):
+ return Self.isGeometryTag(tag) ? "geometry" : "any"
+ case .range:
+ return "range"
+ }
+ }
+
+ static func isGeometryTag(_ tag: UInt64) -> Bool {
+ tag >= SurrealCBORTag.geometryPoint && tag <= SurrealCBORTag.geometryCollection
+ }
+
+ // MARK: - JSON
+
+ private static func jsonFragment(_ value: SurrealValue) -> String {
+ switch value {
+ case .null, .none:
+ return "null"
+ case let .bool(flag):
+ return flag ? "true" : "false"
+ case let .int(number):
+ return String(number)
+ case let .double(number):
+ return formatDouble(number)
+ case let .decimal(text):
+ return text
+ case let .array(items):
+ return "[" + items.map(jsonFragment).joined(separator: ",") + "]"
+ case let .object(pairs):
+ let body = pairs
+ .map { quoted($0.key) + ":" + jsonFragment($0.value) }
+ .joined(separator: ",")
+ return "{" + body + "}"
+ case let .tagged(tag, inner):
+ guard isGeometryTag(tag) else { return jsonFragment(inner) }
+ return geometryJson(tag: tag, value: inner)
+ default:
+ return quoted(value.displayText)
+ }
+ }
+
+ private static func geometryJson(tag: UInt64, value: SurrealValue) -> String {
+ let type = geometryTypeName(tag)
+ guard tag != SurrealCBORTag.geometryCollection else {
+ return "{\"type\":\"GeometryCollection\",\"geometries\":" + jsonFragment(value) + "}"
+ }
+ return "{\"type\":" + quoted(type) + ",\"coordinates\":" + jsonFragment(value) + "}"
+ }
+
+ private static func geometryTypeName(_ tag: UInt64) -> String {
+ switch tag {
+ case 88: return "Point"
+ case 89: return "LineString"
+ case 90: return "Polygon"
+ case 91: return "MultiPoint"
+ case 92: return "MultiLineString"
+ case 93: return "MultiPolygon"
+ default: return "GeometryCollection"
+ }
+ }
+
+ private static func quoted(_ text: String) -> String {
+ var out = "\""
+ for character in text.unicodeScalars {
+ switch character {
+ case "\"":
+ out += "\\\""
+ case "\\":
+ out += "\\\\"
+ case "\n":
+ out += "\\n"
+ case "\r":
+ out += "\\r"
+ case "\t":
+ out += "\\t"
+ default:
+ if character.value < 0x20 {
+ out += String(format: "\\u%04x", character.value)
+ continue
+ }
+ out.unicodeScalars.append(character)
+ }
+ }
+ return out + "\""
+ }
+
+ // MARK: - Scalar formatting
+
+ private static func formatDouble(_ value: Double) -> String {
+ if value == value.rounded(), abs(value) < 1e15 {
+ return String(Int64(value))
+ }
+ return String(value)
+ }
+
+ static func formatDatetime(seconds: Int64, nanoseconds: UInt32) -> String {
+ let secondsPerDay: Int64 = 86_400
+ var days = seconds / secondsPerDay
+ var remainder = seconds % secondsPerDay
+ if remainder < 0 {
+ remainder += secondsPerDay
+ days -= 1
+ }
+
+ let civil = civilFromDays(days)
+ let hour = remainder / 3600
+ let minute = (remainder % 3600) / 60
+ let second = remainder % 60
+
+ var text = String(
+ format: "%04d-%02d-%02dT%02d:%02d:%02d",
+ civil.year, civil.month, civil.day, hour, minute, second
+ )
+ if nanoseconds > 0 {
+ var fraction = String(format: "%09u", nanoseconds)
+ while fraction.hasSuffix("0") {
+ fraction.removeLast()
+ }
+ text += "." + fraction
+ }
+ return text + "Z"
+ }
+
+ private static func civilFromDays(_ days: Int64) -> (year: Int64, month: Int64, day: Int64) {
+ let shifted = days + 719_468
+ let era = (shifted >= 0 ? shifted : shifted - 146_096) / 146_097
+ let dayOfEra = shifted - era * 146_097
+ let yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365
+ let year = yearOfEra + era * 400
+ let dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100)
+ let monthPrime = (5 * dayOfYear + 2) / 153
+ let day = dayOfYear - (153 * monthPrime + 2) / 5 + 1
+ let month = monthPrime + (monthPrime < 10 ? 3 : -9)
+ return (year + (month <= 2 ? 1 : 0), month, day)
+ }
+
+ static func formatDuration(seconds: Int64, nanoseconds: UInt32) -> String {
+ guard seconds != 0 || nanoseconds != 0 else { return "0ns" }
+
+ var remaining = seconds
+ var text = ""
+ let units: [(Int64, String)] = [(604_800, "w"), (86_400, "d"), (3600, "h"), (60, "m"), (1, "s")]
+ for (size, suffix) in units where remaining >= size {
+ text += "\(remaining / size)\(suffix)"
+ remaining %= size
+ }
+
+ var nanos = nanoseconds
+ if nanos >= 1_000_000 {
+ text += "\(nanos / 1_000_000)ms"
+ nanos %= 1_000_000
+ }
+ if nanos >= 1000 {
+ text += "\(nanos / 1000)µs"
+ nanos %= 1000
+ }
+ if nanos > 0 {
+ text += "\(nanos)ns"
+ }
+ return text
+ }
+
+ private static func formatRange(from: SurrealBound?, to: SurrealBound?) -> String {
+ let start = from.map { $0.value.displayText } ?? ""
+ let end = to.map { $0.value.displayText } ?? ""
+ let separator = (from?.isInclusive ?? true) ? ".." : ">.."
+ let closing = (to?.isInclusive ?? false) ? "=" : ""
+ return start + separator + closing + end
+ }
+}
+
+public extension SurrealRecordID {
+ var literal: String {
+ SurrealQL.recordLiteral(self)
+ }
+}
diff --git a/Plugins/SurrealDBDriverPlugin/SurrealValue.swift b/Plugins/SurrealDBDriverPlugin/SurrealValue.swift
new file mode 100644
index 000000000..8c49f0d1f
--- /dev/null
+++ b/Plugins/SurrealDBDriverPlugin/SurrealValue.swift
@@ -0,0 +1,138 @@
+//
+// SurrealValue.swift
+// SurrealDBDriverPlugin
+//
+
+import Foundation
+
+public struct SurrealRecordID: Equatable, Sendable {
+ public let table: String
+ public let id: SurrealValue
+
+ public init(table: String, id: SurrealValue) {
+ self.table = table
+ self.id = id
+ }
+}
+
+public struct SurrealBound: Equatable, Sendable {
+ public let value: SurrealValue
+ public let isInclusive: Bool
+
+ public init(value: SurrealValue, isInclusive: Bool) {
+ self.value = value
+ self.isInclusive = isInclusive
+ }
+}
+
+public indirect enum SurrealValue: Equatable, Sendable {
+ case null
+ case none
+ case bool(Bool)
+ case int(Int64)
+ case double(Double)
+ case string(String)
+ case bytes(Data)
+ case array([SurrealValue])
+ case object([(key: String, value: SurrealValue)])
+ case recordId(SurrealRecordID)
+ case table(String)
+ case uuid(UUID)
+ case decimal(String)
+ case datetime(seconds: Int64, nanoseconds: UInt32)
+ case duration(seconds: Int64, nanoseconds: UInt32)
+ case tagged(tag: UInt64, value: SurrealValue)
+ case range(from: SurrealBound?, to: SurrealBound?)
+
+ public static func == (lhs: SurrealValue, rhs: SurrealValue) -> Bool {
+ switch (lhs, rhs) {
+ case (.null, .null), (.none, .none):
+ return true
+ case let (.bool(a), .bool(b)):
+ return a == b
+ case let (.int(a), .int(b)):
+ return a == b
+ case let (.double(a), .double(b)):
+ return a == b
+ case let (.string(a), .string(b)):
+ return a == b
+ case let (.bytes(a), .bytes(b)):
+ return a == b
+ case let (.array(a), .array(b)):
+ return a == b
+ case let (.object(a), .object(b)):
+ return a.count == b.count && zip(a, b).allSatisfy { $0.key == $1.key && $0.value == $1.value }
+ case let (.recordId(a), .recordId(b)):
+ return a == b
+ case let (.table(a), .table(b)):
+ return a == b
+ case let (.uuid(a), .uuid(b)):
+ return a == b
+ case let (.decimal(a), .decimal(b)):
+ return a == b
+ case let (.datetime(sa, na), .datetime(sb, nb)):
+ return sa == sb && na == nb
+ case let (.duration(sa, na), .duration(sb, nb)):
+ return sa == sb && na == nb
+ case let (.tagged(ta, va), .tagged(tb, vb)):
+ return ta == tb && va == vb
+ case let (.range(fa, ta), .range(fb, tb)):
+ return fa == fb && ta == tb
+ default:
+ return false
+ }
+ }
+}
+
+public extension SurrealValue {
+ subscript(key: String) -> SurrealValue? {
+ guard case let .object(pairs) = self else { return nil }
+ return pairs.first { $0.key == key }?.value
+ }
+
+ var objectPairs: [(key: String, value: SurrealValue)]? {
+ guard case let .object(pairs) = self else { return nil }
+ return pairs
+ }
+
+ var arrayValues: [SurrealValue]? {
+ guard case let .array(values) = self else { return nil }
+ return values
+ }
+
+ var stringValue: String? {
+ switch self {
+ case let .string(value):
+ return value
+ case let .table(name):
+ return name
+ default:
+ return nil
+ }
+ }
+
+ var intValue: Int64? {
+ switch self {
+ case let .int(value):
+ return value
+ case let .double(value):
+ return Int64(exactly: value.rounded())
+ default:
+ return nil
+ }
+ }
+
+ var boolValue: Bool? {
+ guard case let .bool(value) = self else { return nil }
+ return value
+ }
+
+ var isAbsent: Bool {
+ switch self {
+ case .null, .none:
+ return true
+ default:
+ return false
+ }
+ }
+}
diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift
index 9cf396e89..bd472ccef 100644
--- a/Plugins/TableProPluginKit/DriverPlugin.swift
+++ b/Plugins/TableProPluginKit/DriverPlugin.swift
@@ -40,6 +40,7 @@ public protocol DriverPlugin: TableProPlugin {
static var statementCompletions: [CompletionEntry] { get }
static var tableEntityName: String { get }
static var containerEntityName: String { get }
+ static var schemaEntityName: String { get }
static var supportsCascadeDrop: Bool { get }
static var supportsForeignKeyDisable: Bool { get }
static var immutableColumns: [String] { get }
@@ -110,6 +111,7 @@ public extension DriverPlugin {
static var statementCompletions: [CompletionEntry] { [] }
static var tableEntityName: String { "Tables" }
static var containerEntityName: String { "Database" }
+ static var schemaEntityName: String { "Schema" }
static var supportsCascadeDrop: Bool { false }
static var supportsForeignKeyDisable: Bool { true }
static var immutableColumns: [String] { [] }
diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj
index 7dd1afa8e..e2e63cd4b 100644
--- a/TablePro.xcodeproj/project.pbxproj
+++ b/TablePro.xcodeproj/project.pbxproj
@@ -13,6 +13,7 @@
3DD7311CA07CFCA8A996058F /* SnowflakeAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */; };
4355C825A7554BBCB978E1E4 /* SnowflakeSchemaQueries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C399209C2D14499870FBD49 /* SnowflakeSchemaQueries.swift */; };
4CA0E909166145AAB6B6CDD7 /* SnowflakeHTTPRetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */; };
+ 5A1862000000000000000008 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
5A1MPORT000000000000A011 /* TableProImport in Frameworks */ = {isa = PBXBuildFile; productRef = 5A1MPORT000000000000A010 /* TableProImport */; };
5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; };
5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
@@ -33,7 +34,6 @@
5A868000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A868000100000000 /* PostgreSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
5A869000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
- 5ABC147400000000000001 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
5A86A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86A000100000000 /* CSVExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
5A86B000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
@@ -53,6 +53,7 @@
5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
5ABBED742FB55E1400A78382 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
5ABBED802FB55E1400A78382 /* CSVInspectorPlugin.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+ 5ABC147400000000000001 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
5ABQR00100000000000000A1 /* BigQueryAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABQR00200000000000000A1 /* BigQueryAuth.swift */; };
5ABQR00100000000000000A2 /* BigQueryConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABQR00200000000000000A2 /* BigQueryConnection.swift */; };
5ABQR00100000000000000A3 /* BigQueryPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABQR00200000000000000A3 /* BigQueryPlugin.swift */; };
@@ -178,13 +179,6 @@
remoteGlobalIDString = 5A869000000000000;
remoteInfo = DuckDBDriver;
};
- 5ABC14740000000000000F /* PBXContainerItemProxy */ = {
- isa = PBXContainerItemProxy;
- containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */;
- proxyType = 1;
- remoteGlobalIDString = 5A860000000000000;
- remoteInfo = TableProPluginKit;
- };
5A86A000B00000000 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */;
@@ -248,6 +242,13 @@
remoteGlobalIDString = 5ABBED712FB55E1400A78382;
remoteInfo = CSVInspectorPlugin;
};
+ 5ABC14740000000000000F /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 5A860000000000000;
+ remoteInfo = TableProPluginKit;
+ };
5ABCC5AB2F43856700EAF3FC /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */;
@@ -354,6 +355,7 @@
53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeDDLGenerator.swift; sourceTree = ""; };
55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHTTPRetry.swift; sourceTree = ""; };
5A1091C72EF17EDC0055EA7C /* TablePro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TablePro.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 5A1862000000000000000002 /* SurrealDBDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SurrealDBDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A2A9AE12FF52C7D0082A7AC /* DuckDBDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DuckDBDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ElasticsearchDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "tablepro-mcp"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -368,7 +370,6 @@
5A867000100000000 /* RedisDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RedisDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A868000100000000 /* PostgreSQLDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PostgreSQLDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A869000100000000 /* DuckDBDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DuckDBDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
- 5ABC147400000000000003 /* BeancountDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BeancountDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A86A000100000000 /* CSVExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A86B000100000000 /* JSONExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JSONExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A86C000100000000 /* SQLExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -379,6 +380,7 @@
5A86F003100000000 /* CSVImport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVImport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5A87A000100000000 /* CassandraDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CassandraDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVInspectorPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
+ 5ABC147400000000000003 /* BeancountDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BeancountDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; };
5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5ABQR00200000000000000A1 /* BigQueryAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryAuth.swift; sourceTree = ""; };
5ABQR00200000000000000A2 /* BigQueryConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryConnection.swift; sourceTree = ""; };
@@ -427,6 +429,13 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 5A1862000000000000000003 /* Exceptions for "Plugins/SurrealDBDriverPlugin" folder in "SurrealDBDriverPlugin" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 5A1862000000000000000001 /* SurrealDBDriverPlugin */;
+ };
5A32BC082F9D5FC900BAEB5F /* Exceptions for "TablePro" folder in "mcp-server" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@@ -545,13 +554,6 @@
);
target = 5A869000000000000 /* DuckDBDriver */;
};
- 5ABC147400000000000006 /* Exceptions for "Plugins/BeancountDriverPlugin" folder in "BeancountDriver" target */ = {
- isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
- membershipExceptions = (
- Info.plist,
- );
- target = 5ABC147400000000000005 /* BeancountDriver */;
- };
5A86A000900000000 /* Exceptions for "Plugins/CSVExportPlugin" folder in "CSVExport" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@@ -637,6 +639,13 @@
);
target = 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */;
};
+ 5ABC147400000000000006 /* Exceptions for "Plugins/BeancountDriverPlugin" folder in "BeancountDriver" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 5ABC147400000000000005 /* BeancountDriver */;
+ };
5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@@ -666,6 +675,14 @@
path = TablePro;
sourceTree = "";
};
+ 5A1862000000000000000004 /* Plugins/SurrealDBDriverPlugin */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 5A1862000000000000000003 /* Exceptions for "Plugins/SurrealDBDriverPlugin" folder in "SurrealDBDriverPlugin" target */,
+ );
+ path = Plugins/SurrealDBDriverPlugin;
+ sourceTree = "";
+ };
5A32BC012F9D5F1300BAEB5F /* mcp-server */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "mcp-server";
@@ -761,14 +778,6 @@
path = Plugins/DuckDBDriverPlugin;
sourceTree = "";
};
- 5ABC147400000000000007 /* Plugins/BeancountDriverPlugin */ = {
- isa = PBXFileSystemSynchronizedRootGroup;
- exceptions = (
- 5ABC147400000000000006 /* Exceptions for "Plugins/BeancountDriverPlugin" folder in "BeancountDriver" target */,
- );
- path = Plugins/BeancountDriverPlugin;
- sourceTree = "";
- };
5A86A000500000000 /* Plugins/CSVExportPlugin */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -851,6 +860,14 @@
path = Plugins/CSVInspectorPlugin;
sourceTree = "";
};
+ 5ABC147400000000000007 /* Plugins/BeancountDriverPlugin */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 5ABC147400000000000006 /* Exceptions for "Plugins/BeancountDriverPlugin" folder in "BeancountDriver" target */,
+ );
+ path = Plugins/BeancountDriverPlugin;
+ sourceTree = "";
+ };
5ABCC5A82F43856700EAF3FC /* TableProTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = TableProTests;
@@ -886,6 +903,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 5A1862000000000000000006 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 5A1862000000000000000008 /* TableProPluginKit.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5A32BBFD2F9D5F1300BAEB5F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -982,14 +1007,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
- 5ABC147400000000000008 /* Frameworks */ = {
- isa = PBXFrameworksBuildPhase;
- buildActionMask = 2147483647;
- files = (
- 5ABC147400000000000001 /* TableProPluginKit.framework in Frameworks */,
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
5A86A000300000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -1070,6 +1087,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 5ABC147400000000000008 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 5ABC147400000000000001 /* TableProPluginKit.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5ABCC5A42F43856700EAF3FC /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -1151,6 +1176,7 @@
5ABQR00500000000000000B0 /* Plugins/BigQueryDriverPlugin */,
5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */,
5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */,
+ 5A1862000000000000000004 /* Plugins/SurrealDBDriverPlugin */,
5A3BE6FE2F97DB0100611C1F /* Plugins/LibSQLDriverPlugin */,
5A1091C92EF17EDC0055EA7C /* TablePro */,
5A860000500000000 /* Plugins/TableProPluginKit */,
@@ -1214,6 +1240,7 @@
5ADDB00300000000000000A0 /* DynamoDBDriverPlugin.tableplugin */,
5ABQR00300000000000000A0 /* BigQueryDriverPlugin.tableplugin */,
5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */,
+ 5A1862000000000000000002 /* SurrealDBDriverPlugin.tableplugin */,
5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */,
5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */,
5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */,
@@ -1380,6 +1407,28 @@
productReference = 5A1091C72EF17EDC0055EA7C /* TablePro.app */;
productType = "com.apple.product-type.application";
};
+ 5A1862000000000000000001 /* SurrealDBDriverPlugin */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 5A186200000000000000000B /* Build configuration list for PBXNativeTarget "SurrealDBDriverPlugin" */;
+ buildPhases = (
+ 5A1862000000000000000005 /* Sources */,
+ 5A1862000000000000000006 /* Frameworks */,
+ 5A1862000000000000000007 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 5A1862000000000000000004 /* Plugins/SurrealDBDriverPlugin */,
+ );
+ name = SurrealDBDriverPlugin;
+ packageProductDependencies = (
+ );
+ productName = SurrealDBDriverPlugin;
+ productReference = 5A1862000000000000000002 /* SurrealDBDriverPlugin.tableplugin */;
+ productType = "com.apple.product-type.bundle";
+ };
5A32BBFF2F9D5F1300BAEB5F /* mcp-server */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5A32BC042F9D5F1300BAEB5F /* Build configuration list for PBXNativeTarget "mcp-server" */;
@@ -1631,27 +1680,6 @@
productReference = 5A2A9AE12FF52C7D0082A7AC /* DuckDBDriver.tableplugin */;
productType = "com.apple.product-type.bundle";
};
- 5ABC147400000000000005 /* BeancountDriver */ = {
- isa = PBXNativeTarget;
- buildConfigurationList = 5ABC147400000000000009 /* Build configuration list for PBXNativeTarget "BeancountDriver" */;
- buildPhases = (
- 5ABC14740000000000000A /* Sources */,
- 5ABC147400000000000008 /* Frameworks */,
- 5ABC14740000000000000B /* Resources */,
- );
- buildRules = (
- );
- dependencies = (
- 5ABC147400000000000010 /* PBXTargetDependency */,
- );
- fileSystemSynchronizedGroups = (
- 5ABC147400000000000007 /* Plugins/BeancountDriverPlugin */,
- );
- name = BeancountDriver;
- productName = BeancountDriver;
- productReference = 5ABC147400000000000003 /* BeancountDriver.tableplugin */;
- productType = "com.apple.product-type.bundle";
- };
5A86A000000000000 /* CSVExport */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5A86A000800000000 /* Build configuration list for PBXNativeTarget "CSVExport" */;
@@ -1852,6 +1880,27 @@
productReference = 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */;
productType = "com.apple.product-type.bundle";
};
+ 5ABC147400000000000005 /* BeancountDriver */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 5ABC147400000000000009 /* Build configuration list for PBXNativeTarget "BeancountDriver" */;
+ buildPhases = (
+ 5ABC14740000000000000A /* Sources */,
+ 5ABC147400000000000008 /* Frameworks */,
+ 5ABC14740000000000000B /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 5ABC147400000000000010 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 5ABC147400000000000007 /* Plugins/BeancountDriverPlugin */,
+ );
+ name = BeancountDriver;
+ productName = BeancountDriver;
+ productReference = 5ABC147400000000000003 /* BeancountDriver.tableplugin */;
+ productType = "com.apple.product-type.bundle";
+ };
5ABCC5A62F43856700EAF3FC /* TableProTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5ABCC5AD2F43856700EAF3FC /* Build configuration list for PBXNativeTarget "TableProTests" */;
@@ -2058,9 +2107,6 @@
5A869000000000000 = {
CreatedOnToolsVersion = 26.2;
};
- 5ABC147400000000000005 = {
- CreatedOnToolsVersion = 26.5;
- };
5A86A000000000000 = {
CreatedOnToolsVersion = 26.2;
};
@@ -2076,6 +2122,9 @@
5A86E000000000000 = {
CreatedOnToolsVersion = 26.2;
};
+ 5ABC147400000000000005 = {
+ CreatedOnToolsVersion = 26.5;
+ };
5ABCC5A62F43856700EAF3FC = {
CreatedOnToolsVersion = 26.2;
TestTargetID = 5A1091C62EF17EDC0055EA7C;
@@ -2145,6 +2194,7 @@
5AF00A142FB9000000000001 /* TableProUITests */,
5AEA8B292F6808270040461A /* EtcdDriverPlugin */,
5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */,
+ 5A1862000000000000000001 /* SurrealDBDriverPlugin */,
5ADDB00600000000000000B0 /* DynamoDBDriverPlugin */,
5E1A500600000000000000B0 /* ElasticsearchDriverPlugin */,
5ABQR00600000000000000B0 /* BigQueryDriverPlugin */,
@@ -2171,6 +2221,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 5A1862000000000000000007 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5A3BE6F62F97DA8100611C1F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -2248,13 +2305,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
- 5ABC14740000000000000B /* Resources */ = {
- isa = PBXResourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
5A86A000400000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -2325,6 +2375,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 5ABC14740000000000000B /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5ABCC5A52F43856700EAF3FC /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -2376,9 +2433,6 @@
};
/* End PBXResourcesBuildPhase section */
-/* Begin PBXShellScriptBuildPhase section */
-/* End PBXShellScriptBuildPhase section */
-
/* Begin PBXSourcesBuildPhase section */
329606C9C47DD1FA4F8F5350 /* Sources */ = {
isa = PBXSourcesBuildPhase;
@@ -2411,6 +2465,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 5A1862000000000000000005 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5A32BBFC2F9D5F1300BAEB5F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -2495,13 +2556,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
- 5ABC14740000000000000A /* Sources */ = {
- isa = PBXSourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
5A86A000200000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -2572,6 +2626,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 5ABC14740000000000000A /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5ABCC5A32F43856700EAF3FC /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -2703,11 +2764,6 @@
target = 5A869000000000000 /* DuckDBDriver */;
targetProxy = 5A869000B00000000 /* PBXContainerItemProxy */;
};
- 5ABC147400000000000010 /* PBXTargetDependency */ = {
- isa = PBXTargetDependency;
- target = 5A860000000000000 /* TableProPluginKit */;
- targetProxy = 5ABC14740000000000000F /* PBXContainerItemProxy */;
- };
5A86A000C00000000 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 5A86A000000000000 /* CSVExport */;
@@ -2753,6 +2809,11 @@
target = 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */;
targetProxy = 5ABBED7E2FB55E1400A78382 /* PBXContainerItemProxy */;
};
+ 5ABC147400000000000010 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 5A860000000000000 /* TableProPluginKit */;
+ targetProxy = 5ABC14740000000000000F /* PBXContainerItemProxy */;
+ };
5ABCC5AC2F43856700EAF3FC /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 5A1091C62EF17EDC0055EA7C /* TablePro */;
@@ -3108,6 +3169,52 @@
};
name = Release;
};
+ 5A1862000000000000000009 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Plugins/SurrealDBDriverPlugin/Info.plist;
+ INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SurrealDBPlugin";
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SurrealDBDriverPlugin;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_VERSION = 5.9;
+ WRAPPER_EXTENSION = tableplugin;
+ };
+ name = Debug;
+ };
+ 5A186200000000000000000A /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Plugins/SurrealDBDriverPlugin/Info.plist;
+ INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SurrealDBPlugin";
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SurrealDBDriverPlugin;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_VERSION = 5.9;
+ WRAPPER_EXTENSION = tableplugin;
+ };
+ name = Release;
+ };
5A32BC052F9D5F1300BAEB5F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -3862,56 +3969,6 @@
};
name = Release;
};
- 5ABC14740000000000000D /* Debug */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- CODE_SIGN_STYLE = Automatic;
- COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = "";
- ENABLE_USER_SCRIPT_SANDBOXING = NO;
- GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = Plugins/BeancountDriverPlugin/Info.plist;
- INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).BeancountPlugin";
- LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
- MACOSX_DEPLOYMENT_TARGET = 14.0;
- MARKETING_VERSION = 1.0;
- OTHER_LDFLAGS = "-lsqlite3";
- PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.BeancountDriver;
- PRODUCT_NAME = "$(TARGET_NAME)";
- SDKROOT = macosx;
- SKIP_INSTALL = YES;
- SUPPORTED_PLATFORMS = macosx;
- SWIFT_VERSION = 5.9;
- WRAPPER_EXTENSION = tableplugin;
- };
- name = Debug;
- };
- 5ABC14740000000000000E /* Release */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- CODE_SIGN_STYLE = Automatic;
- COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = "";
- ENABLE_USER_SCRIPT_SANDBOXING = NO;
- GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = Plugins/BeancountDriverPlugin/Info.plist;
- INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).BeancountPlugin";
- LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
- MACOSX_DEPLOYMENT_TARGET = 14.0;
- MARKETING_VERSION = 1.0;
- OTHER_LDFLAGS = "-lsqlite3";
- PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.BeancountDriver;
- PRODUCT_NAME = "$(TARGET_NAME)";
- SDKROOT = macosx;
- SKIP_INSTALL = YES;
- SUPPORTED_PLATFORMS = macosx;
- SWIFT_VERSION = 5.9;
- WRAPPER_EXTENSION = tableplugin;
- };
- name = Release;
- };
5A86A000600000000 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -4410,6 +4467,56 @@
};
name = Release;
};
+ 5ABC14740000000000000D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Plugins/BeancountDriverPlugin/Info.plist;
+ INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).BeancountPlugin";
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = "-lsqlite3";
+ PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.BeancountDriver;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_VERSION = 5.9;
+ WRAPPER_EXTENSION = tableplugin;
+ };
+ name = Debug;
+ };
+ 5ABC14740000000000000E /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Plugins/BeancountDriverPlugin/Info.plist;
+ INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).BeancountPlugin";
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = "-lsqlite3";
+ PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.BeancountDriver;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_VERSION = 5.9;
+ WRAPPER_EXTENSION = tableplugin;
+ };
+ name = Release;
+ };
5ABCC5AE2F43856700EAF3FC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -4778,6 +4885,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 5A186200000000000000000B /* Build configuration list for PBXNativeTarget "SurrealDBDriverPlugin" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 5A1862000000000000000009 /* Debug */,
+ 5A186200000000000000000A /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
5A32BC042F9D5F1300BAEB5F /* Build configuration list for PBXNativeTarget "mcp-server" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -4886,15 +5002,6 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
- 5ABC147400000000000009 /* Build configuration list for PBXNativeTarget "BeancountDriver" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- 5ABC14740000000000000D /* Debug */,
- 5ABC14740000000000000E /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
- };
5A86A000800000000 /* Build configuration list for PBXNativeTarget "CSVExport" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -4985,6 +5092,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 5ABC147400000000000009 /* Build configuration list for PBXNativeTarget "BeancountDriver" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 5ABC14740000000000000D /* Debug */,
+ 5ABC14740000000000000E /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
5ABCC5AD2F43856700EAF3FC /* Build configuration list for PBXNativeTarget "TableProTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/TablePro/Assets.xcassets/surrealdb-icon.imageset/Contents.json b/TablePro/Assets.xcassets/surrealdb-icon.imageset/Contents.json
new file mode 100644
index 000000000..4f27213f3
--- /dev/null
+++ b/TablePro/Assets.xcassets/surrealdb-icon.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images": [
+ {
+ "filename": "surrealdb.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true,
+ "template-rendering-intent": "template"
+ }
+}
diff --git a/TablePro/Assets.xcassets/surrealdb-icon.imageset/surrealdb.svg b/TablePro/Assets.xcassets/surrealdb-icon.imageset/surrealdb.svg
new file mode 100644
index 000000000..f4d46063f
--- /dev/null
+++ b/TablePro/Assets.xcassets/surrealdb-icon.imageset/surrealdb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift
index ef383bbd7..00d86f21e 100644
--- a/TablePro/Core/Plugins/PluginManager+Registration.swift
+++ b/TablePro/Core/Plugins/PluginManager+Registration.swift
@@ -401,6 +401,11 @@ extension PluginManager {
containerEntityName(for: databaseType) + "s"
}
+ func schemaEntityName(for databaseType: DatabaseType) -> String {
+ PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
+ .schema.schemaEntityName ?? "Schema"
+ }
+
func supportsCascadeDrop(for databaseType: DatabaseType) -> Bool {
PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
.capabilities.supportsCascadeDrop ?? false
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift
index 62e50128c..3d6d0327b 100644
--- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift
@@ -807,7 +807,7 @@ extension PluginMetadataRegistry {
tagline: String(localized: "Distributed SQLite by Turso")
)
)),
- ] + cloudPluginDefaults() + elasticsearchPluginDefaults()
+ ] + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults()
}
// swiftlint:enable function_body_length
}
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+SurrealDBDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+SurrealDBDefaults.swift
new file mode 100644
index 000000000..a8b43cff8
--- /dev/null
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry+SurrealDBDefaults.swift
@@ -0,0 +1,145 @@
+//
+// PluginMetadataRegistry+SurrealDBDefaults.swift
+// TablePro
+//
+
+import Foundation
+import TableProPluginKit
+
+extension PluginMetadataRegistry {
+ func surrealDBPluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] {
+ [
+ ("SurrealDB", PluginMetadataSnapshot(
+ displayName: "SurrealDB", iconName: "surrealdb-icon", defaultPort: 8_000,
+ requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false,
+ isDownloadable: true, primaryUrlScheme: "surrealdb", parameterStyle: .dollar,
+ navigationModel: .standard, explainVariants: surrealDBExplainVariants,
+ pathFieldRole: .database,
+ supportsHealthMonitor: true, urlSchemes: ["surrealdb"],
+ postConnectActions: [.selectSchemaFromLastSession],
+ brandColorHex: "#FF00A0",
+ queryLanguageName: "SurrealQL", editorLanguage: .custom("surrealql"),
+ connectionMode: .network, supportsDatabaseSwitching: true,
+ supportsColumnReorder: false,
+ capabilities: PluginMetadataSnapshot.CapabilityFlags(
+ supportsSchemaSwitching: true,
+ supportsImport: false,
+ supportsExport: true,
+ supportsSSH: true,
+ supportsSSL: true,
+ supportsCascadeDrop: false,
+ supportsForeignKeyDisable: false,
+ supportsReadOnlyMode: true,
+ supportsQueryProgress: false,
+ requiresReconnectForDatabaseSwitch: false,
+ supportsDropDatabase: true,
+ supportsOpportunisticTLS: false
+ ),
+ schema: PluginMetadataSnapshot.SchemaInfo(
+ defaultSchemaName: "",
+ defaultGroupName: "main",
+ tableEntityName: "Tables",
+ containerEntityName: "Namespace",
+ schemaEntityName: "Database",
+ defaultPrimaryKeyColumn: "id",
+ immutableColumns: ["id"],
+ systemDatabaseNames: [],
+ systemSchemaNames: [],
+ fileExtensions: [],
+ databaseGroupingStrategy: .bySchema,
+ structureColumnFields: [.name, .type, .nullable]
+ ),
+ editor: PluginMetadataSnapshot.EditorConfig(
+ sqlDialect: nil,
+ statementCompletions: surrealDBCompletions,
+ columnTypesByCategory: surrealDBColumnTypes
+ ),
+ connection: PluginMetadataSnapshot.ConnectionConfig(
+ additionalConnectionFields: surrealDBConnectionFields(),
+ category: .document,
+ tagline: String(localized: "Multi-model database with SurrealQL")
+ )
+ )),
+ ]
+ }
+}
+
+private let surrealDBExplainVariants: [ExplainVariant] = [
+ ExplainVariant(id: "explain", label: "Explain", sqlPrefix: "EXPLAIN"),
+ ExplainVariant(id: "explainFull", label: "Explain Full", sqlPrefix: "EXPLAIN FULL"),
+]
+
+private let surrealDBCompletions: [CompletionEntry] = [
+ CompletionEntry(label: "SELECT", insertText: "SELECT * FROM table LIMIT 100"),
+ CompletionEntry(label: "CREATE", insertText: "CREATE table SET field = value"),
+ CompletionEntry(label: "UPDATE", insertText: "UPDATE table:id SET field = value"),
+ CompletionEntry(label: "UPSERT", insertText: "UPSERT table:id SET field = value"),
+ CompletionEntry(label: "DELETE", insertText: "DELETE table:id"),
+ CompletionEntry(label: "RELATE", insertText: "RELATE from:id->edge->to:id SET field = value"),
+ CompletionEntry(label: "USE", insertText: "USE NS namespace DB database"),
+ CompletionEntry(label: "INFO FOR DB", insertText: "INFO FOR DB"),
+ CompletionEntry(label: "INFO FOR TABLE", insertText: "INFO FOR TABLE table"),
+ CompletionEntry(label: "DEFINE TABLE", insertText: "DEFINE TABLE table SCHEMAFULL"),
+ CompletionEntry(label: "DEFINE FIELD", insertText: "DEFINE FIELD field ON table TYPE string"),
+ CompletionEntry(label: "DEFINE INDEX", insertText: "DEFINE INDEX name ON table FIELDS field UNIQUE"),
+ CompletionEntry(label: "REMOVE TABLE", insertText: "REMOVE TABLE table"),
+ CompletionEntry(label: "count", insertText: "SELECT count() AS total FROM table GROUP ALL"),
+]
+
+private let surrealDBColumnTypes: [String: [String]] = [
+ "Integer": ["int"],
+ "Float": ["float", "decimal", "number"],
+ "String": ["string", "uuid"],
+ "Date": ["datetime", "duration"],
+ "Binary": ["bytes"],
+ "Boolean": ["bool"],
+ "Structured": ["object", "array", "set", "geometry", "record", "any"],
+]
+
+func surrealDBConnectionFields() -> [ConnectionField] {
+ [
+ ConnectionField(
+ id: "sdbAuthLevel",
+ label: String(localized: "Auth Level"),
+ defaultValue: "root",
+ fieldType: .dropdown(options: [
+ .init(value: "root", label: String(localized: "Root")),
+ .init(value: "namespace", label: String(localized: "Namespace")),
+ .init(value: "database", label: String(localized: "Database")),
+ .init(value: "record", label: String(localized: "Record Access")),
+ .init(value: "token", label: String(localized: "Token")),
+ ]),
+ section: .authentication
+ ),
+ ConnectionField(
+ id: "sdbToken",
+ label: String(localized: "Token"),
+ placeholder: "JWT",
+ fieldType: .secure,
+ section: .authentication,
+ hidesPassword: true,
+ visibleWhen: FieldVisibilityRule(fieldId: "sdbAuthLevel", values: ["token"])
+ ),
+ ConnectionField(
+ id: "sdbAccess",
+ label: String(localized: "Access Method"),
+ placeholder: "user",
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: "sdbAuthLevel", values: ["record"])
+ ),
+ ConnectionField(
+ id: "sdbDatabase",
+ label: String(localized: "Database"),
+ placeholder: String(localized: "The database this user belongs to"),
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: "sdbAuthLevel", values: ["database", "record"])
+ ),
+ ConnectionField(
+ id: "sdbSkipTLSVerify",
+ label: String(localized: "Skip TLS Verification"),
+ defaultValue: "false",
+ fieldType: .toggle,
+ section: .advanced
+ ),
+ ]
+}
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift
index fd6f8db5e..c0ca27aa3 100644
--- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift
@@ -92,6 +92,7 @@ struct PluginMetadataSnapshot: Sendable {
let defaultGroupName: String
let tableEntityName: String
let containerEntityName: String
+ let schemaEntityName: String
let defaultPrimaryKeyColumn: String?
let immutableColumns: [String]
let systemDatabaseNames: [String]
@@ -100,6 +101,34 @@ struct PluginMetadataSnapshot: Sendable {
let databaseGroupingStrategy: GroupingStrategy
let structureColumnFields: [StructureColumnField]
+ init(
+ defaultSchemaName: String,
+ defaultGroupName: String,
+ tableEntityName: String,
+ containerEntityName: String,
+ schemaEntityName: String = "Schema",
+ defaultPrimaryKeyColumn: String?,
+ immutableColumns: [String],
+ systemDatabaseNames: [String],
+ systemSchemaNames: [String],
+ fileExtensions: [String],
+ databaseGroupingStrategy: GroupingStrategy,
+ structureColumnFields: [StructureColumnField]
+ ) {
+ self.defaultSchemaName = defaultSchemaName
+ self.defaultGroupName = defaultGroupName
+ self.tableEntityName = tableEntityName
+ self.containerEntityName = containerEntityName
+ self.schemaEntityName = schemaEntityName
+ self.defaultPrimaryKeyColumn = defaultPrimaryKeyColumn
+ self.immutableColumns = immutableColumns
+ self.systemDatabaseNames = systemDatabaseNames
+ self.systemSchemaNames = systemSchemaNames
+ self.fileExtensions = fileExtensions
+ self.databaseGroupingStrategy = databaseGroupingStrategy
+ self.structureColumnFields = structureColumnFields
+ }
+
static let defaults = SchemaInfo(
defaultSchemaName: "public",
defaultGroupName: "main",
@@ -243,6 +272,7 @@ struct PluginMetadataSnapshot: Sendable {
defaultGroupName: schema.defaultGroupName,
tableEntityName: schema.tableEntityName,
containerEntityName: source.schema.containerEntityName,
+ schemaEntityName: source.schema.schemaEntityName,
defaultPrimaryKeyColumn: schema.defaultPrimaryKeyColumn,
immutableColumns: schema.immutableColumns,
systemDatabaseNames: schema.systemDatabaseNames,
@@ -971,6 +1001,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
defaultGroupName: driverType.defaultGroupName,
tableEntityName: driverType.tableEntityName,
containerEntityName: driverType.containerEntityName,
+ schemaEntityName: driverType.schemaEntityName,
defaultPrimaryKeyColumn: driverType.defaultPrimaryKeyColumn,
immutableColumns: driverType.immutableColumns,
systemDatabaseNames: driverType.systemDatabaseNames,
@@ -1005,7 +1036,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
return .relational
case "Redshift", "ClickHouse", "DuckDB", "BigQuery":
return .analytical
- case "MongoDB", "Elasticsearch":
+ case "MongoDB", "Elasticsearch", "SurrealDB":
return .document
case "Redis":
return .keyValue
@@ -1041,6 +1072,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
case "libSQL": return String(localized: "Distributed SQLite by Turso")
case "DynamoDB": return String(localized: "AWS managed key-value/document store")
case "BigQuery": return String(localized: "Google Cloud serverless data warehouse")
+ case "SurrealDB": return String(localized: "Multi-model database with SurrealQL")
default: return ""
}
}
diff --git a/TablePro/Info.plist b/TablePro/Info.plist
index adc61a784..86c1f2480 100644
--- a/TablePro/Info.plist
+++ b/TablePro/Info.plist
@@ -331,6 +331,7 @@
etcds
d1
libsql
+ surrealdb
CFBundleTypeRole
Viewer
diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift
index c51ada1c1..01e15a450 100644
--- a/TablePro/Models/Connection/DatabaseConnection.swift
+++ b/TablePro/Models/Connection/DatabaseConnection.swift
@@ -47,6 +47,7 @@ extension DatabaseType {
static let turso = DatabaseType(rawValue: "Turso")
static let beancount = DatabaseType(rawValue: "Beancount")
static let elasticsearch = DatabaseType(rawValue: "Elasticsearch")
+ static let surrealdb = DatabaseType(rawValue: "SurrealDB")
}
extension DatabaseType: Codable {
diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings
index 2a953cc02..da9475879 100644
--- a/TablePro/Resources/Localizable.xcstrings
+++ b/TablePro/Resources/Localizable.xcstrings
@@ -313,6 +313,9 @@
}
}
}
+ },
+ "“%@” owns database objects" : {
+
},
"\"%@\" was changed since you opened it. Review the diff and choose how to resolve." : {
"localizations" : {
@@ -2067,6 +2070,9 @@
}
}
}
+ },
+ "%@_name" : {
+
},
"%@, %@" : {
"localizations" : {
@@ -2101,6 +2107,9 @@
}
}
}
+ },
+ "%@, pinned" : {
+
},
"%@: %@" : {
"localizations" : {
@@ -2358,9 +2367,21 @@
}
}
}
+ },
+ "%1$@ (%2$@)" : {
+
+ },
+ "%1$@ +%2$lld" : {
+
},
"%1$@, %2$@" : {
"shouldTranslate" : false
+ },
+ "%1$lld of %2$lld statements were applied. This connection does not roll back user and role changes." : {
+
+ },
+ "%1$lld users, %2$lld roles" : {
+
},
"%d cells selected, rows %d to %d, columns %d to %d" : {
"localizations" : {
@@ -3105,6 +3126,15 @@
}
}
}
+ },
+ "%lld inside" : {
+
+ },
+ "%lld objects" : {
+
+ },
+ "%lld objects selected" : {
+
},
"%lld of %lld" : {
"extractionState" : "stale",
@@ -3209,6 +3239,12 @@
}
}
}
+ },
+ "%lld pending changes" : {
+
+ },
+ "%lld privileges" : {
+
},
"%lld pt" : {
"extractionState" : "stale",
@@ -3238,6 +3274,9 @@
}
}
}
+ },
+ "%lld roles own database objects" : {
+
},
"%lld row(s) affected" : {
"localizations" : {
@@ -5337,6 +5376,9 @@
}
}
}
+ },
+ "A connection can use one connection method at a time. Disable the other one to use the Cloud SQL Auth Proxy." : {
+
},
"A connection can use one tunnel at a time. Disable the SSH Tunnel to use a Cloudflare Tunnel." : {
"localizations" : {
@@ -5365,8 +5407,12 @@
}
}
}
+ },
+ "A connection can use only one connection method at a time. Enabled: %@." : {
+
},
"A connection cannot use SSH and Cloudflare tunnels at the same time." : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -5421,6 +5467,9 @@
}
}
}
+ },
+ "A different file already exists at %@." : {
+
},
"A fast, lightweight native macOS database client" : {
"localizations" : {
@@ -5449,6 +5498,12 @@
}
}
}
+ },
+ "A key from the xAI Console bills xAI API credits. Grok 4.5 and Grok 4.3 are available." : {
+
+ },
+ "A new password will be set when you apply changes." : {
+
},
"A newer TablePro is required to load this plugin." : {
"localizations" : {
@@ -5505,6 +5560,12 @@
}
}
}
+ },
+ "A service account key is required" : {
+
+ },
+ "A superuser bypasses all permission checks." : {
+
},
"A sync conflict was detected and needs to be resolved." : {
"localizations" : {
@@ -5533,6 +5594,9 @@
}
}
}
+ },
+ "A user or role with this name already exists." : {
+
},
"About TablePro" : {
"localizations" : {
@@ -5680,6 +5744,9 @@
}
}
}
+ },
+ "Access Method" : {
+
},
"Access uses your ChatGPT subscription (Plus, Pro, Business, or Enterprise) and follows OpenAI's terms. This is an unofficial interface that may change." : {
"localizations" : {
@@ -8033,6 +8100,9 @@
}
}
}
+ },
+ "All (%lld)" : {
+
},
"All %d rows selected" : {
"localizations" : {
@@ -8203,6 +8273,9 @@
}
}
}
+ },
+ "All Objects" : {
+
},
"All rights reserved." : {
"extractionState" : "stale",
@@ -9670,6 +9743,9 @@
}
}
}
+ },
+ "Application Default Credentials" : {
+
},
"Applied" : {
"extractionState" : "stale",
@@ -10072,6 +10148,9 @@
}
}
}
+ },
+ "Apply User and Role Changes" : {
+
},
"Applying or clearing filters will reload data and discard all unsaved changes." : {
"localizations" : {
@@ -10750,6 +10829,9 @@
}
}
}
+ },
+ "Attributes" : {
+
},
"Auth" : {
"localizations" : {
@@ -10778,6 +10860,9 @@
}
}
}
+ },
+ "Auth Level" : {
+
},
"Auth Method" : {
"localizations" : {
@@ -11432,6 +11517,9 @@
}
}
}
+ },
+ "Available after SET ROLE %@." : {
+
},
"Available in all connections" : {
@@ -12523,6 +12611,9 @@
}
}
}
+ },
+ "Bulk actions" : {
+
},
"Bundle ID" : {
"localizations" : {
@@ -12862,6 +12953,15 @@
}
}
}
+ },
+ "Can grant these privileges to others." : {
+
+ },
+ "Can grant to others" : {
+
+ },
+ "Can log in" : {
+
},
"Cancel" : {
"localizations" : {
@@ -13404,6 +13504,21 @@
}
}
}
+ },
+ "Cannot use Cloud SQL Auth Proxy and Cloudflare Tunnel at the same time" : {
+
+ },
+ "Cannot use Cloud SQL Auth Proxy and SSH Tunnel at the same time" : {
+
+ },
+ "Cannot use Cloudflare Tunnel and Cloud SQL Auth Proxy at the same time" : {
+
+ },
+ "Cannot use Cloudflare Tunnel and SSH Tunnel at the same time" : {
+
+ },
+ "Cannot use SSH Tunnel and Cloud SQL Auth Proxy at the same time" : {
+
},
"Cannot use SSH Tunnel and Cloudflare Tunnel at the same time" : {
"localizations" : {
@@ -13859,6 +13974,9 @@
}
}
}
+ },
+ "Change Attributes" : {
+
},
"Change Color" : {
"localizations" : {
@@ -13944,6 +14062,18 @@
}
}
}
+ },
+ "Change Password" : {
+
+ },
+ "Change password for %@" : {
+
+ },
+ "Change Password for %@" : {
+
+ },
+ "Change Password…" : {
+
},
"Change Primary Key" : {
"localizations" : {
@@ -14233,6 +14363,9 @@
}
}
}
+ },
+ "Checking owned objects…" : {
+
},
"Chinook (Sample)" : {
"localizations" : {
@@ -14718,6 +14851,9 @@
}
}
}
+ },
+ "Choose what happens to them when the role is dropped." : {
+
},
"Choose where to save the dump of “%@”." : {
"localizations" : {
@@ -16481,6 +16617,21 @@
}
}
}
+ },
+ "Cloud SQL Auth Proxy" : {
+
+ },
+ "Cloud SQL Auth Proxy disconnected. Click to reconnect." : {
+
+ },
+ "Cloud SQL Instance" : {
+
+ },
+ "cloud-sql-proxy not found. Install it with `brew install cloud-sql-proxy`, download it above, or choose the binary." : {
+
+ },
+ "cloud-sql-proxy was not found. Install it with `brew install cloud-sql-proxy`, or download it in the Cloud SQL Auth Proxy settings." : {
+
},
"Cloudflare Access needs a browser sign-in. Sign in at %@, then reconnect." : {
"localizations" : {
@@ -17484,6 +17635,9 @@
}
}
}
+ },
+ "Command Line" : {
+
},
"Command Preview" : {
"extractionState" : "stale",
@@ -18097,6 +18251,9 @@
}
}
}
+ },
+ "Connect over private IP" : {
+
},
"Connect to a saved database" : {
"localizations" : {
@@ -18670,6 +18827,9 @@
}
}
}
+ },
+ "Connection limit" : {
+
},
"Connection lost" : {
"extractionState" : "stale",
@@ -20507,6 +20667,18 @@
}
}
}
+ },
+ "Copy Privileges" : {
+
+ },
+ "Copy Privileges from %@" : {
+
+ },
+ "Copy Privileges From…" : {
+
+ },
+ "Copy privileges to %@" : {
+
},
"Copy Query" : {
"localizations" : {
@@ -21295,6 +21467,9 @@
}
}
}
+ },
+ "Could not remove %@." : {
+
},
"Could Not Reset Sample" : {
"localizations" : {
@@ -21379,6 +21554,15 @@
}
}
}
+ },
+ "Could not start the sign-in listener. Please try again." : {
+
+ },
+ "Could not write %@." : {
+
+ },
+ "Could not write the Google Cloud service account key to a temporary file." : {
+
},
"Could not write to file. Check that the file is writable." : {
"localizations" : {
@@ -21528,6 +21712,9 @@
}
}
}
+ },
+ "Create %@" : {
+
},
"Create a connection to get started" : {
"extractionState" : "stale",
@@ -22161,6 +22348,9 @@
}
}
}
+ },
+ "Credentials" : {
+
},
"CRITICAL: Transaction rollback failed - database may be in inconsistent state: %@" : {
"extractionState" : "stale",
@@ -22304,6 +22494,9 @@
}
}
}
+ },
+ "Current %@" : {
+
},
"Current %@: %@ (⌘K to %@)" : {
"localizations" : {
@@ -22488,6 +22681,7 @@
}
},
"Current schema" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -24006,6 +24200,7 @@
}
},
"database_name" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -26405,6 +26600,9 @@
}
}
}
+ },
+ "Disable Cloudflare Tunnel" : {
+
},
"Disable foreign key checks" : {
"extractionState" : "stale",
@@ -27275,6 +27473,9 @@
}
}
}
+ },
+ "Download cloud-sql-proxy..." : {
+
},
"Downloads" : {
"localizations" : {
@@ -27757,6 +27958,9 @@
}
}
}
+ },
+ "Drop owned objects" : {
+
},
"Drop Partition" : {
"localizations" : {
@@ -27813,6 +28017,9 @@
}
}
}
+ },
+ "Drop Role" : {
+
},
"Drop selected database" : {
"extractionState" : "stale",
@@ -27932,6 +28139,9 @@
}
}
}
+ },
+ "Drop…" : {
+
},
"Dropping..." : {
"extractionState" : "stale",
@@ -28358,6 +28568,9 @@
}
}
}
+ },
+ "Dynamic" : {
+
},
"e.g., Claude Code on VPS" : {
"localizations" : {
@@ -29045,6 +29258,9 @@
}
}
}
+ },
+ "Edit…" : {
+
},
"Editable Hex" : {
"localizations" : {
@@ -29186,6 +29402,9 @@
}
}
}
+ },
+ "Effective" : {
+
},
"ElastiCache IAM authentication requires TLS. Enable SSL in the connection's SSL settings." : {
"localizations" : {
@@ -29467,6 +29686,9 @@
}
}
}
+ },
+ "Enable Cloud SQL Auth Proxy" : {
+
},
"Enable Cloudflare Tunnel" : {
"localizations" : {
@@ -33550,6 +33772,9 @@
}
}
}
+ },
+ "Failed to load" : {
+
},
"Failed to Load" : {
"localizations" : {
@@ -35482,6 +35707,9 @@
}
}
}
+ },
+ "Filter objects" : {
+
},
"Filter operator" : {
"localizations" : {
@@ -35596,6 +35824,12 @@
}
}
}
+ },
+ "Filter privileges" : {
+
+ },
+ "Filter roles" : {
+
},
"Filter settings" : {
"extractionState" : "stale",
@@ -35913,6 +36147,9 @@
}
}
}
+ },
+ "Find this on the instance's overview page in the Google Cloud console." : {
+
},
"Find..." : {
"localizations" : {
@@ -36537,6 +36774,12 @@
}
}
}
+ },
+ "Forget" : {
+
+ },
+ "Forget All" : {
+
},
"Format JSON" : {
"extractionState" : "stale",
@@ -37662,6 +37905,21 @@
}
}
}
+ },
+ "Grant %1$@ on %2$@" : {
+
+ },
+ "Grant All" : {
+
+ },
+ "Grant privileges to %@" : {
+
+ },
+ "Granted" : {
+
+ },
+ "Granted on %@" : {
+
},
"Graphite" : {
"extractionState" : "stale",
@@ -37946,6 +38204,9 @@
}
}
}
+ },
+ "Has unsaved changes" : {
+
},
"Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : {
"localizations" : {
@@ -38340,6 +38601,9 @@
}
}
}
+ },
+ "Host:" : {
+
},
"Host: %@" : {
"localizations" : {
@@ -38735,6 +38999,9 @@
}
}
}
+ },
+ "Identity" : {
+
},
"If macOS asks for your login password, click Always Allow on each prompt." : {
"localizations" : {
@@ -40435,6 +40702,12 @@
}
}
}
+ },
+ "Inherited automatically." : {
+
+ },
+ "Inherited from %@" : {
+
},
"Inline Configuration" : {
"localizations" : {
@@ -41145,6 +41418,12 @@
}
}
}
+ },
+ "Instance connection name" : {
+
+ },
+ "Instance connection name must be project:region:instance" : {
+
},
"Integer" : {
"localizations" : {
@@ -43003,6 +43282,12 @@
}
}
}
+ },
+ "Kind" : {
+
+ },
+ "Kind:" : {
+
},
"Language:" : {
"localizations" : {
@@ -44092,6 +44377,9 @@
}
}
}
+ },
+ "Limits" : {
+
},
"Line %lld: %@" : {
"localizations" : {
@@ -44295,6 +44583,9 @@
}
}
}
+ },
+ "Links you chose to always allow. TablePro connects without asking. Only connections on this machine can be trusted." : {
+
},
"List" : {
"localizations" : {
@@ -45424,6 +45715,9 @@
}
}
}
+ },
+ "Loading users and roles..." : {
+
},
"Loading..." : {
"extractionState" : "stale",
@@ -47214,6 +47508,15 @@
}
}
}
+ },
+ "Member of" : {
+
+ },
+ "Member Of" : {
+
+ },
+ "Membership" : {
+
},
"Memory Limit" : {
"localizations" : {
@@ -47495,6 +47798,9 @@
}
}
}
+ },
+ "Mixed Selection" : {
+
},
"Mode" : {
"localizations" : {
@@ -48231,6 +48537,9 @@
}
}
}
+ },
+ "Multi-model database with SurrealQL" : {
+
},
"Multiple statements are not permitted for this client" : {
"localizations" : {
@@ -48435,7 +48744,6 @@
}
},
"Name:" : {
- "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -48462,6 +48770,9 @@
}
}
}
+ },
+ "Namespace" : {
+
},
"Navigate to referenced row" : {
"extractionState" : "stale",
@@ -49281,6 +49592,9 @@
}
}
}
+ },
+ "New password:" : {
+
},
"New Query" : {
"localizations" : {
@@ -49310,41 +49624,41 @@
}
}
},
- "New Query, favorite, or folder" : {
+ "New query tab" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Yeni sorgu, sık kullanılan veya klasör"
+ "value" : "Yeni sorgu sekmesi"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Truy vấn mới, mục yêu thích hoặc thư mục"
+ "value" : "Tab truy vấn mới"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "新建查询、收藏或文件夹"
+ "value" : "新建查询标签页"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
- "value" : "新增查詢、我的最愛或資料夾"
+ "value" : "新增查詢分頁"
}
}
}
},
- "New query tab" : {
- "extractionState" : "stale",
+ "New Query Tab" : {
"localizations" : {
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Yeni sorgu sekmesi"
+ "value" : "Yeni Sorgu Sekmesi"
}
},
"vi" : {
@@ -49367,59 +49681,59 @@
}
}
},
- "New Query Tab" : {
+ "New Query Tab (⌘T)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Yeni Sorgu Sekmesi"
+ "value" : "Yeni Sorgu Sekmesi (⌘T)"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tab truy vấn mới"
+ "value" : "Tab truy vấn mới (⌘T)"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "新建查询标签页"
+ "value" : "新建查询标签页 (⌘T)"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
- "value" : "新增查詢分頁"
+ "value" : "新增查詢分頁 (⌘T)"
}
}
}
},
- "New Query Tab (⌘T)" : {
- "extractionState" : "stale",
+ "New Query, favorite, or folder" : {
"localizations" : {
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Yeni Sorgu Sekmesi (⌘T)"
+ "value" : "Yeni sorgu, sık kullanılan veya klasör"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tab truy vấn mới (⌘T)"
+ "value" : "Truy vấn mới, mục yêu thích hoặc thư mục"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "新建查询标签页 (⌘T)"
+ "value" : "新建查询、收藏或文件夹"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
- "value" : "新增查詢分頁 (⌘T)"
+ "value" : "新增查詢、我的最愛或資料夾"
}
}
}
@@ -49622,6 +49936,9 @@
},
"New Trigger" : {
+ },
+ "New User or Role" : {
+
},
"New View" : {
"localizations" : {
@@ -50107,6 +50424,9 @@
}
}
}
+ },
+ "No available local port for the Cloud SQL Auth Proxy." : {
+
},
"No available local port for the Cloudflare tunnel." : {
"localizations" : {
@@ -51069,6 +51389,9 @@
}
}
}
+ },
+ "No links are trusted yet." : {
+
},
"No matching %@" : {
"extractionState" : "stale",
@@ -51470,6 +51793,9 @@
}
}
}
+ },
+ "No Object Selected" : {
+
},
"No objects found" : {
"extractionState" : "stale",
@@ -51642,6 +51968,15 @@
}
}
}
+ },
+ "No privileges" : {
+
+ },
+ "No Privileges" : {
+
+ },
+ "No privileges can be granted at this level." : {
+
},
"No providers configured" : {
"localizations" : {
@@ -52348,6 +52683,9 @@
}
}
}
+ },
+ "No Users or Roles" : {
+
},
"No valid rows found in clipboard data." : {
"localizations" : {
@@ -52545,6 +52883,9 @@
}
}
}
+ },
+ "Not browsable on this connection" : {
+
},
"Not configured" : {
"localizations" : {
@@ -52714,6 +53055,9 @@
}
}
}
+ },
+ "Not granted" : {
+
},
"not in list" : {
"localizations" : {
@@ -52939,6 +53283,9 @@
}
}
}
+ },
+ "Not signed in to xAI." : {
+
},
"Not supported for this database." : {
"localizations" : {
@@ -53023,6 +53370,9 @@
}
}
}
+ },
+ "Not visible" : {
+
},
"NOW()" : {
"extractionState" : "stale",
@@ -53475,6 +53825,9 @@
}
}
}
+ },
+ "Object" : {
+
},
"Off" : {
"localizations" : {
@@ -54407,6 +54760,9 @@
}
}
}
+ },
+ "Open in Query Editor" : {
+
},
"Open in Window" : {
"localizations" : {
@@ -54919,6 +55275,9 @@
}
}
}
+ },
+ "Opens database URLs from the terminal, such as a DDEV project database." : {
+
},
"Operation cancelled by user" : {
"localizations" : {
@@ -56171,6 +56530,9 @@
}
}
}
+ },
+ "Partly granted" : {
+
},
"Passphrase" : {
"localizations" : {
@@ -56514,6 +56876,9 @@
}
}
}
+ },
+ "Password:" : {
+
},
"Passwords will be encrypted with the passphrase you provide." : {
"extractionState" : "stale",
@@ -57223,6 +57588,9 @@
}
}
}
+ },
+ "Plain-text accounting ledgers" : {
+
},
"Plan: %@" : {
"localizations" : {
@@ -59572,6 +59940,12 @@
}
}
}
+ },
+ "Privilege" : {
+
+ },
+ "Privileges" : {
+
},
"Pro" : {
"localizations" : {
@@ -61985,6 +62359,12 @@
}
}
}
+ },
+ "Reassign owned objects" : {
+
+ },
+ "Reassign to" : {
+
},
"Recent" : {
"localizations" : {
@@ -62154,6 +62534,9 @@
}
}
}
+ },
+ "Recently Closed" : {
+
},
"Recheck" : {
"localizations" : {
@@ -62268,6 +62651,9 @@
}
}
}
+ },
+ "Record Access" : {
+
},
"Recording shortcut" : {
"localizations" : {
@@ -63986,6 +64372,9 @@
}
}
}
+ },
+ "Reopen Closed Tab" : {
+
},
"Reopen Last Session" : {
"localizations" : {
@@ -65504,6 +65893,9 @@
}
}
}
+ },
+ "Review & Apply…" : {
+
},
"Revoke" : {
"localizations" : {
@@ -65532,6 +65924,15 @@
}
}
}
+ },
+ "Revoke %1$@ on %2$@" : {
+
+ },
+ "Revoke All" : {
+
+ },
+ "Revoke privileges from %@" : {
+
},
"Revoked" : {
"localizations" : {
@@ -65645,8 +66046,12 @@
}
}
}
+ },
+ "Role Attributes" : {
+
},
"root" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -65673,6 +66078,9 @@
}
}
}
+ },
+ "Root" : {
+
},
"Root Level" : {
"localizations" : {
@@ -67483,6 +67891,9 @@
}
}
}
+ },
+ "Schema and table privileges in this database are only visible when the connection is using it." : {
+
},
"Schema change was not authorized" : {
"localizations" : {
@@ -68713,6 +69124,9 @@
}
}
}
+ },
+ "Select %@" : {
+
},
"Select %@ file to import" : {
"localizations" : {
@@ -68938,6 +69352,9 @@
}
}
}
+ },
+ "Select a user or role to view its privileges." : {
+
},
"Select All" : {
"localizations" : {
@@ -68994,6 +69411,9 @@
}
}
}
+ },
+ "Select an object on the left to edit its privileges." : {
+
},
"Select Connections" : {
"localizations" : {
@@ -69164,6 +69584,9 @@
}
}
}
+ },
+ "Select objects of the same kind to edit their privileges." : {
+
},
"Select Plugin" : {
"localizations" : {
@@ -69197,6 +69620,7 @@
},
"Select schema" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -69775,6 +70199,9 @@
}
}
}
+ },
+ "Service account key (JSON)" : {
+
},
"Service Name" : {
"extractionState" : "stale",
@@ -70034,6 +70461,9 @@
}
}
}
+ },
+ "Set Password" : {
+
},
"Set special value" : {
"extractionState" : "stale",
@@ -70063,6 +70493,9 @@
}
}
}
+ },
+ "Set the connection's username to the IAM principal (a user email, or `name@project.iam` for a service account). The database password is not used." : {
+
},
"Set to NULL" : {
"localizations" : {
@@ -70941,6 +71374,9 @@
},
"Show Object Comments" : {
+ },
+ "Show password" : {
+
},
"Show Previous Tab" : {
"localizations" : {
@@ -71028,8 +71464,12 @@
}
}
}
+ },
+ "Show System %@s" : {
+
},
"Show System Schemas" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -71620,6 +72060,9 @@
}
}
}
+ },
+ "Sign in with xAI" : {
+
},
"Sign Out" : {
"localizations" : {
@@ -71872,6 +72315,9 @@
}
}
}
+ },
+ "Signed in with xAI" : {
+
},
"Signing in…" : {
"localizations" : {
@@ -74351,6 +74797,9 @@
}
}
}
+ },
+ "Starts and stops the Cloud SQL Auth Proxy with this connection and routes it through a local port." : {
+
},
"starts with" : {
"localizations" : {
@@ -75151,6 +75600,9 @@
}
}
}
+ },
+ "Stored in the macOS Keychain and written to a temporary file only while the proxy runs." : {
+
},
"Streaming failed: %@" : {
"localizations" : {
@@ -77147,6 +77599,12 @@
}
}
}
+ },
+ "tablepro command" : {
+
+ },
+ "TablePro could not do this for you. Run this in Terminal instead:" : {
+
},
"TablePro couldn't reach the plugin registry to update this plugin. Check your connection and reopen TablePro." : {
"localizations" : {
@@ -77315,6 +77773,9 @@
}
}
}
+ },
+ "Tables, views, and functions owned by this role will be deleted." : {
+
},
"Tablespace" : {
"extractionState" : "stale",
@@ -78160,6 +78621,18 @@
}
}
}
+ },
+ "The Cloud SQL Auth Proxy did not become ready in time: %@" : {
+
+ },
+ "The Cloud SQL Auth Proxy did not become ready in time." : {
+
+ },
+ "The Cloud SQL Auth Proxy failed to start: %@" : {
+
+ },
+ "The Cloud SQL Auth Proxy failed to start." : {
+
},
"The Cloudflare tunnel did not become ready in time: %@" : {
"localizations" : {
@@ -78387,6 +78860,9 @@
}
}
}
+ },
+ "The database this user belongs to" : {
+
},
"The destructive query to execute" : {
"localizations" : {
@@ -78628,6 +79104,9 @@
}
}
}
+ },
+ "The instance connection name must be in the form project:region:instance." : {
+
},
"The license has been suspended." : {
"localizations" : {
@@ -78770,6 +79249,9 @@
}
}
}
+ },
+ "The passwords do not match." : {
+
},
"The previous code wasn't accepted. Wait for your authenticator to refresh, then enter the new code." : {
"localizations" : {
@@ -79230,6 +79712,15 @@
},
"There are no connections to publish." : {
+ },
+ "These changes alter %@, the account this connection uses." : {
+
+ },
+ "These changes drop %@, the account this connection uses." : {
+
+ },
+ "These changes revoke every privilege from %@, the account this connection uses." : {
+
},
"This %1$@ has no %2$@ yet." : {
"localizations" : {
@@ -79286,6 +79777,9 @@
}
}
}
+ },
+ "This connection does not support user and role management." : {
+
},
"This connection was deleted on another device or window. Your changes were not saved." : {
"localizations" : {
@@ -80259,6 +80753,9 @@
}
}
}
+ },
+ "This server has no users or roles you can manage." : {
+
},
"This sets %lld loaded rows. Review and Save to apply." : {
"localizations" : {
@@ -80491,6 +80988,9 @@
}
}
}
+ },
+ "This user can grant this privilege to others." : {
+
},
"This value is too large to parse. Use raw mode to inspect it as text." : {
"localizations" : {
@@ -82689,6 +83189,9 @@
}
}
}
+ },
+ "Trusted Links" : {
+
},
"Try a different search term" : {
"extractionState" : "stale",
@@ -83002,6 +83505,12 @@
}
}
}
+ },
+ "Unable to Load Privileges" : {
+
+ },
+ "Unable to Load Users and Roles" : {
+
},
"Under MCP Servers click \"+ Add Custom Server\", select the Local tab, paste the JSON below, then click Add Server" : {
"localizations" : {
@@ -83115,6 +83624,9 @@
}
}
}
+ },
+ "Undo Staged Drop" : {
+
},
"Uninstall" : {
"localizations" : {
@@ -83566,6 +84078,7 @@
}
},
"Unpin" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -83592,6 +84105,9 @@
}
}
}
+ },
+ "Unpin Result" : {
+
},
"Unset" : {
"extractionState" : "stale",
@@ -84496,6 +85012,9 @@
}
}
}
+ },
+ "Use % to allow any host." : {
+
},
"Use ~/.pgpass" : {
"extractionState" : "stale",
@@ -84525,8 +85044,12 @@
}
}
}
+ },
+ "Use as Active %@" : {
+
},
"Use as Active Database" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -84555,6 +85078,7 @@
}
},
"Use as Active Schema" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -84697,6 +85221,9 @@
}
}
}
+ },
+ "Use IAM database authentication" : {
+
},
"Use Password File" : {
"localizations" : {
@@ -84809,6 +85336,9 @@
}
}
}
+ },
+ "Use the instance's private IP address instead of its public IP." : {
+
},
"Use your Cursor subscription with no API key. Requires the Cursor CLI." : {
"localizations" : {
@@ -84837,6 +85367,9 @@
}
}
}
+ },
+ "Use your SuperGrok or X Premium+ subscription with no API key. Sign-in opens the Grok Build consent screen. This is an unofficial interface that may change." : {
+
},
"User" : {
"localizations" : {
@@ -84865,6 +85398,9 @@
}
}
}
+ },
+ "User and role changes were not authorized" : {
+
},
"User approval timed out after 30 seconds" : {
"localizations" : {
@@ -85061,6 +85597,12 @@
}
}
}
+ },
+ "Users & Roles" : {
+
+ },
+ "Uses this Mac's Application Default Credentials. Run `gcloud auth application-default login` first, or set GOOGLE_APPLICATION_CREDENTIALS." : {
+
},
"using %@" : {
"localizations" : {
@@ -85913,6 +86455,9 @@
}
}
}
+ },
+ "Verify:" : {
+
},
"Version" : {
"localizations" : {
@@ -86804,6 +87349,12 @@
}
}
}
+ },
+ "Will be created when you apply changes" : {
+
+ },
+ "Will be dropped when you apply changes" : {
+
},
"Window Background" : {
"localizations" : {
@@ -86974,6 +87525,9 @@
}
}
}
+ },
+ "xAI did not return an access token." : {
+
},
"XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" : {
"localizations" : {
diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift
index dc5ee88ad..41605d1d2 100644
--- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift
+++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift
@@ -79,9 +79,9 @@ struct GeneralPaneView: View {
if PluginManager.shared.supportsDatabaseSwitching(for: type) {
Section(String(localized: "Connection")) {
TextField(
- String(localized: "Database"),
+ containerEntityName,
text: $coordinator.network.database,
- prompt: Text(String(localized: "database_name"))
+ prompt: Text(containerEntityPlaceholder)
)
}
} else {
@@ -92,9 +92,9 @@ struct GeneralPaneView: View {
hostFieldsView
if PluginManager.shared.requiresAuthentication(for: type) {
TextField(
- String(localized: "Database"),
+ containerEntityName,
text: $coordinator.network.database,
- prompt: Text(String(localized: "database_name"))
+ prompt: Text(containerEntityPlaceholder)
)
}
}
@@ -115,6 +115,14 @@ struct GeneralPaneView: View {
}
}
+ private var containerEntityName: String {
+ PluginManager.shared.containerEntityName(for: type)
+ }
+
+ private var containerEntityPlaceholder: String {
+ String(format: String(localized: "%@_name"), containerEntityName.lowercased())
+ }
+
@ViewBuilder
private var hostFieldsView: some View {
let connectionFields = coordinator.network.connectionFields
diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift
index f205a7e47..aa64a74ae 100644
--- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift
+++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift
@@ -468,6 +468,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject {
private func rowContext() -> DatabaseTreeRowContext {
DatabaseTreeRowContext(
+ databaseType: databaseType,
activeDatabase: activeDatabase,
activeSchema: activeSchema,
systemSchemas: systemSchemas,
diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift
index f079ea97c..f565f5844 100644
--- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift
+++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift
@@ -23,6 +23,7 @@ struct DatabaseTreeRowActions {
}
struct DatabaseTreeRowContext {
+ let databaseType: DatabaseType
let activeDatabase: String?
let activeSchema: String?
let systemSchemas: Set
@@ -36,6 +37,14 @@ struct DatabaseTreeRowView: View {
let context: DatabaseTreeRowContext
let actions: DatabaseTreeRowActions
+ private var containerEntityName: String {
+ PluginManager.shared.containerEntityName(for: context.databaseType)
+ }
+
+ private var schemaEntityName: String {
+ PluginManager.shared.schemaEntityName(for: context.databaseType)
+ }
+
var body: some View {
if hasContextMenu {
row.contextMenu { menuItems }
@@ -159,7 +168,7 @@ struct DatabaseTreeRowView: View {
actions.clearRecents()
}
case .database(let metadata):
- Button(String(localized: "Use as Active Database")) {
+ Button(String(format: String(localized: "Use as Active %@"), containerEntityName)) {
actions.setActiveDatabase(metadata.name)
}
.disabled(metadata.name == context.activeDatabase)
@@ -167,7 +176,7 @@ struct DatabaseTreeRowView: View {
actions.refreshDatabase(metadata.name)
}
case .schema(let database, let schema):
- Button(String(localized: "Use as Active Schema")) {
+ Button(String(format: String(localized: "Use as Active %@"), schemaEntityName)) {
actions.setActiveSchema(database, schema)
}
.disabled(database == context.activeDatabase && schema == context.activeSchema)
diff --git a/TablePro/Views/Sidebar/SchemaPickerControl.swift b/TablePro/Views/Sidebar/SchemaPickerControl.swift
index 177658538..65dc11c60 100644
--- a/TablePro/Views/Sidebar/SchemaPickerControl.swift
+++ b/TablePro/Views/Sidebar/SchemaPickerControl.swift
@@ -22,6 +22,10 @@ struct SchemaPickerControl: View {
Set(PluginManager.shared.systemSchemaNames(for: databaseType))
}
+ private var entityName: String {
+ PluginManager.shared.schemaEntityName(for: databaseType)
+ }
+
private var userSchemas: [String] {
allSchemas.filter { !systemSchemas.contains($0) }
}
@@ -47,7 +51,7 @@ struct SchemaPickerControl: View {
var body: some View {
if Self.shouldShow(schemaCount: allSchemas.count) {
Menu {
- Picker(String(localized: "Schema"), selection: selectedSchema) {
+ Picker(entityName, selection: selectedSchema) {
ForEach(userSchemas, id: \.self) { schema in
Text(schema).tag(schema)
}
@@ -62,7 +66,10 @@ struct SchemaPickerControl: View {
if !visibleSystemSchemas.isEmpty {
Divider()
- Toggle(String(localized: "Show System Schemas"), isOn: $showSystemSchemas)
+ Toggle(
+ String(format: String(localized: "Show System %@s"), entityName),
+ isOn: $showSystemSchemas
+ )
}
Divider()
@@ -70,11 +77,11 @@ struct SchemaPickerControl: View {
Task { await schemaService.refresh(connectionId: connectionId) }
}
} label: {
- Text(currentSchema ?? String(localized: "Select schema"))
+ Text(currentSchema ?? String(format: String(localized: "Select %@"), entityName.lowercased()))
}
.menuStyle(.borderlessButton)
.fixedSize()
- .accessibilityLabel(String(localized: "Current schema"))
+ .accessibilityLabel(String(format: String(localized: "Current %@"), entityName.lowercased()))
}
}
}
diff --git a/TableProTests/PluginTestSources/SurrealCBOR.swift b/TableProTests/PluginTestSources/SurrealCBOR.swift
new file mode 120000
index 000000000..f58454cb5
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealCBOR.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealCBOR.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealCellCoder.swift b/TableProTests/PluginTestSources/SurrealCellCoder.swift
new file mode 120000
index 000000000..7abaa67f7
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealCellCoder.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealCellCoder.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealDBConnectionConfig.swift b/TableProTests/PluginTestSources/SurrealDBConnectionConfig.swift
new file mode 120000
index 000000000..3c62fe0f4
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealDBConnectionConfig.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealDBConnectionConfig.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealDBConnectionFields.swift b/TableProTests/PluginTestSources/SurrealDBConnectionFields.swift
new file mode 120000
index 000000000..7a0ca9369
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealDBConnectionFields.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealDBConnectionFields.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealDBError.swift b/TableProTests/PluginTestSources/SurrealDBError.swift
new file mode 120000
index 000000000..baa9f54b5
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealDBError.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealDBError.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealFieldKind.swift b/TableProTests/PluginTestSources/SurrealFieldKind.swift
new file mode 120000
index 000000000..4f6a02bc6
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealFieldKind.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealFieldKind.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealInfoParser.swift b/TableProTests/PluginTestSources/SurrealInfoParser.swift
new file mode 120000
index 000000000..2a66017ac
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealInfoParser.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealInfoParser.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealQL.swift b/TableProTests/PluginTestSources/SurrealQL.swift
new file mode 120000
index 000000000..0e05922d0
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealQL.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealQL.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealQueryBuilder.swift b/TableProTests/PluginTestSources/SurrealQueryBuilder.swift
new file mode 120000
index 000000000..291f9baa0
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealQueryBuilder.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealQueryBuilder.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealRowFlattener.swift b/TableProTests/PluginTestSources/SurrealRowFlattener.swift
new file mode 120000
index 000000000..f1aa0bada
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealRowFlattener.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealRowFlattener.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealStatementGenerator.swift b/TableProTests/PluginTestSources/SurrealStatementGenerator.swift
new file mode 120000
index 000000000..11ddb719e
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealStatementGenerator.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealStatementGenerator.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealValue+Display.swift b/TableProTests/PluginTestSources/SurrealValue+Display.swift
new file mode 120000
index 000000000..483692196
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealValue+Display.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealValue+Display.swift
\ No newline at end of file
diff --git a/TableProTests/PluginTestSources/SurrealValue.swift b/TableProTests/PluginTestSources/SurrealValue.swift
new file mode 120000
index 000000000..035a6b035
--- /dev/null
+++ b/TableProTests/PluginTestSources/SurrealValue.swift
@@ -0,0 +1 @@
+../../Plugins/SurrealDBDriverPlugin/SurrealValue.swift
\ No newline at end of file
diff --git a/TableProTests/Plugins/SurrealDBCBORTests.swift b/TableProTests/Plugins/SurrealDBCBORTests.swift
new file mode 100644
index 000000000..fa10c8f64
--- /dev/null
+++ b/TableProTests/Plugins/SurrealDBCBORTests.swift
@@ -0,0 +1,178 @@
+//
+// SurrealDBCBORTests.swift
+// TableProTests
+//
+
+import Foundation
+import Testing
+
+@Suite("SurrealDB - CBOR codec")
+struct SurrealDBCBORTests {
+ private func roundTrip(_ value: SurrealValue) throws -> SurrealValue {
+ try SurrealCBOR.decode(SurrealCBOR.encode(value))
+ }
+
+ @Test("Scalars round-trip")
+ func scalars() throws {
+ let values: [SurrealValue] = [
+ .null,
+ .none,
+ .bool(true),
+ .bool(false),
+ .int(0),
+ .int(23),
+ .int(24),
+ .int(255),
+ .int(65_535),
+ .int(4_294_967_295),
+ .int(9_223_372_036_854_775_807),
+ .int(-1),
+ .int(-1000),
+ .double(1.5),
+ .double(-0.25),
+ .string(""),
+ .string("hello"),
+ .string("unicode ✓ 日本語"),
+ .bytes(Data([0x00, 0x01, 0xFF])),
+ ]
+ for value in values {
+ #expect(try roundTrip(value) == value)
+ }
+ }
+
+ @Test("Tagged SurrealDB types round-trip")
+ func taggedTypes() throws {
+ let uuid = try #require(UUID(uuidString: "018a6680-0000-7000-8000-000000000000"))
+ let values: [SurrealValue] = [
+ .recordId(SurrealRecordID(table: "person", id: .string("alice"))),
+ .recordId(SurrealRecordID(table: "person", id: .int(10))),
+ .decimal("12.345"),
+ .datetime(seconds: 1_726_403_696, nanoseconds: 789_000_000),
+ .datetime(seconds: -14_182_940, nanoseconds: 0),
+ .duration(seconds: 3600, nanoseconds: 0),
+ .duration(seconds: 0, nanoseconds: 1),
+ .uuid(uuid),
+ .table("person"),
+ .array([.int(1), .string("two")]),
+ .object([(key: "a", value: .int(1)), (key: "b", value: .none)]),
+ ]
+ for value in values {
+ #expect(try roundTrip(value) == value)
+ }
+ }
+
+ @Test("Decodes the byte sequences a live SurrealDB server sends")
+ func serverBytes() throws {
+ let taggedRecordIdOfPersonAlice = Data(
+ [0xC8, 0x82, 0x66, 0x70, 0x65, 0x72, 0x73, 0x6F, 0x6E, 0x65, 0x61, 0x6C, 0x69, 0x63, 0x65]
+ )
+ #expect(
+ try SurrealCBOR.decode(taggedRecordIdOfPersonAlice)
+ == .recordId(SurrealRecordID(table: "person", id: .string("alice")))
+ )
+
+ let taggedDecimalOf12Point345 = Data([0xCA, 0x66, 0x31, 0x32, 0x2E, 0x33, 0x34, 0x35])
+ #expect(try SurrealCBOR.decode(taggedDecimalOf12Point345) == .decimal("12.345"))
+
+ let taggedNone = Data([0xC6, 0xF6])
+ let plainNull = Data([0xF6])
+ #expect(try SurrealCBOR.decode(taggedNone) == .none)
+ #expect(try SurrealCBOR.decode(plainNull) == .null)
+ #expect(SurrealValue.none != SurrealValue.null)
+ }
+
+ @Test("Range keeps its inclusive and exclusive bounds")
+ func ranges() throws {
+ let value = SurrealValue.range(
+ from: SurrealBound(value: .int(1), isInclusive: true),
+ to: SurrealBound(value: .int(10), isInclusive: false)
+ )
+ #expect(try roundTrip(value) == value)
+ }
+
+ @Test("Malformed input throws instead of trapping")
+ func malformed() {
+ #expect(throws: SurrealCBORError.self) { try SurrealCBOR.decode(Data()) }
+ #expect(throws: SurrealCBORError.self) { try SurrealCBOR.decode(Data([0x64, 0x61])) }
+ #expect(throws: SurrealCBORError.self) { try SurrealCBOR.decode(Data([0x1B, 0x00])) }
+ #expect(throws: SurrealCBORError.self) { try SurrealCBOR.decode(Data([0x9F, 0x01])) }
+ }
+
+ @Test("A length header near Int max throws instead of overflowing")
+ func lengthOverflow() {
+ // byte string (major 2) claiming an 8-byte length of Int64.max
+ #expect(throws: SurrealCBORError.self) {
+ try SurrealCBOR.decode(Data([0x5B, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]))
+ }
+ // text string (major 3) claiming the same
+ #expect(throws: SurrealCBORError.self) {
+ try SurrealCBOR.decode(Data([0x7B, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]))
+ }
+ }
+
+ @Test("Deeply nested input throws nestingTooDeep instead of overflowing the stack")
+ func nestingDepth() {
+ var deep = Data(repeating: 0x81, count: 100_000)
+ deep.append(0xF6)
+ #expect(throws: SurrealCBORError.nestingTooDeep) { try SurrealCBOR.decode(deep) }
+
+ // A legitimately deep-but-bounded value still decodes.
+ var shallow = Data(repeating: 0x81, count: 10)
+ shallow.append(0x01)
+ #expect(throws: Never.self) { try SurrealCBOR.decode(shallow) }
+ }
+
+ @Test("Unknown tags fall back to the wrapped value")
+ func unknownTag() throws {
+ let unknown = Data([0xD8, 0x63, 0x01])
+ #expect(try SurrealCBOR.decode(unknown) == .int(1))
+ }
+}
+
+@Suite("SurrealDB - value display")
+struct SurrealDBDisplayTests {
+ @Test("Record ids render as table:id")
+ func recordIds() {
+ #expect(SurrealValue.recordId(SurrealRecordID(table: "person", id: .string("alice"))).displayText == "person:alice")
+ #expect(SurrealValue.recordId(SurrealRecordID(table: "person", id: .int(10))).displayText == "person:10")
+ #expect(SurrealValue.recordId(SurrealRecordID(table: "person", id: .string("a b"))).displayText == "person:`a b`")
+ }
+
+ @Test("Datetimes keep nanosecond precision and handle pre-epoch")
+ func datetimes() {
+ #expect(SurrealValue.datetime(seconds: 1_726_403_696, nanoseconds: 789_000_000).displayText
+ == "2024-09-15T12:34:56.789Z")
+ #expect(SurrealValue.datetime(seconds: 1_704_067_200, nanoseconds: 123_456_789).displayText
+ == "2024-01-01T00:00:00.123456789Z")
+ #expect(SurrealValue.datetime(seconds: -14_182_940, nanoseconds: 0).displayText
+ == "1969-07-20T20:17:40Z")
+ }
+
+ @Test("Durations render as SurrealQL literals")
+ func durations() {
+ #expect(SurrealValue.duration(seconds: 3600, nanoseconds: 0).displayText == "1h")
+ #expect(SurrealValue.duration(seconds: 5400, nanoseconds: 0).displayText == "1h30m")
+ #expect(SurrealValue.duration(seconds: 0, nanoseconds: 500_000_000).displayText == "500ms")
+ #expect(SurrealValue.duration(seconds: 0, nanoseconds: 1).displayText == "1ns")
+ #expect(SurrealValue.duration(seconds: 0, nanoseconds: 0).displayText == "0ns")
+ }
+
+ @Test("Nested values render as compact JSON, capped")
+ func nested() {
+ let object = SurrealValue.object([
+ (key: "city", value: .string("Ho Chi Minh")),
+ (key: "n", value: .int(2)),
+ ])
+ #expect(object.displayText == #"{"city":"Ho Chi Minh","n":2}"#)
+ #expect(SurrealValue.array([.string("red"), .string("blue")]).displayText == #"["red","blue"]"#)
+
+ let huge = SurrealValue.array(Array(repeating: .string(String(repeating: "x", count: 100)), count: 500))
+ #expect(huge.displayText.count <= SurrealValue.maxSerializedLength + 3)
+ }
+
+ @Test("Geometry renders as GeoJSON")
+ func geometry() {
+ let point = SurrealValue.tagged(tag: 88, value: .array([.double(12.34), .double(56.78)]))
+ #expect(point.displayText == #"{"type":"Point","coordinates":[12.34,56.78]}"#)
+ }
+}
diff --git a/TableProTests/Plugins/SurrealDBDriverTests.swift b/TableProTests/Plugins/SurrealDBDriverTests.swift
new file mode 100644
index 000000000..b31560b79
--- /dev/null
+++ b/TableProTests/Plugins/SurrealDBDriverTests.swift
@@ -0,0 +1,582 @@
+//
+// SurrealDBDriverTests.swift
+// TableProTests
+//
+
+import Foundation
+import TableProPluginKit
+import Testing
+
+@Suite("SurrealDB - SurrealQL escaping")
+struct SurrealQLTests {
+ @Test("Identifiers are backtick-quoted only when they need it")
+ func identifiers() {
+ #expect(SurrealQL.quoteIdentifier("person") == "person")
+ #expect(SurrealQL.quoteIdentifier("odd-table") == "`odd-table`")
+ #expect(SurrealQL.quoteIdentifier("with space") == "`with space`")
+ #expect(SurrealQL.quoteIdentifier("select") == "`select`")
+ #expect(SurrealQL.quoteIdentifier("we`ird") == "`we\\`ird`")
+ }
+
+ @Test("String literals escape quotes and backslashes")
+ func literals() {
+ #expect(SurrealQL.stringLiteral("O'Brien") == "'O\\'Brien'")
+ #expect(SurrealQL.stringLiteral("back\\slash") == "'back\\\\slash'")
+ #expect(SurrealQL.stringLiteral("plain") == "'plain'")
+ }
+
+ @Test("Record ids parse from every form SurrealDB emits")
+ func recordIds() {
+ #expect(SurrealQL.parseRecordId("person:alice") == SurrealRecordID(table: "person", id: .string("alice")))
+ #expect(SurrealQL.parseRecordId("person:10") == SurrealRecordID(table: "person", id: .int(10)))
+ #expect(SurrealQL.parseRecordId("person:`a:b`") == SurrealRecordID(table: "person", id: .string("a:b")))
+ #expect(SurrealQL.parseRecordId("person:\u{27E8}10\u{27E9}") == SurrealRecordID(table: "person", id: .string("10")))
+ #expect(SurrealQL.parseRecordId("alice", fallbackTable: "person")
+ == SurrealRecordID(table: "person", id: .string("alice")))
+ #expect(SurrealQL.parseRecordId("") == nil)
+ }
+
+ @Test("A backticked string id is not re-inferred as an int")
+ func quotedStringIdStaysString() {
+ #expect(SurrealQL.parseRecordId("person:`10`") == SurrealRecordID(table: "person", id: .string("10")))
+ #expect(SurrealQL.parseRecordId("person:\u{27E8}10\u{27E9}") == SurrealRecordID(table: "person", id: .string("10")))
+ #expect(SurrealQL.parseRecordId("person:10") == SurrealRecordID(table: "person", id: .int(10)))
+ }
+
+ @Test("Record ids round-trip through their display literal")
+ func recordIdRoundTrip() {
+ let ids: [SurrealValue] = [
+ .string("alice"),
+ .int(10),
+ .string("10"),
+ .string("a:b"),
+ .string("has space"),
+ ]
+ for id in ids {
+ let record = SurrealRecordID(table: "person", id: id)
+ let literal = SurrealValue.recordId(record).displayText
+ #expect(SurrealQL.parseRecordId(literal) == record, "\(literal) did not round-trip")
+ }
+ }
+
+ @Test("A record id whose table needs quoting round-trips without changing table")
+ func oddTableRoundTrip() {
+ let record = SurrealRecordID(table: "a:b", id: .int(1))
+ let literal = SurrealValue.recordId(record).displayText
+ #expect(literal == "`a:b`:1")
+ #expect(SurrealQL.parseRecordId(literal) == record)
+ }
+}
+
+@Suite("SurrealDB - query builder")
+struct SurrealQueryBuilderTests {
+ private let scope = SurrealScope(namespace: "ns", database: "db")
+
+ @Test("Browse emits real, self-scoping SurrealQL")
+ func browse() {
+ let query = SurrealQueryBuilder.browse(
+ table: "person", scope: scope,
+ sortColumns: [(column: "name", ascending: false)], limit: 100, offset: 200
+ )
+ #expect(query.contains("USE NS ns DB db;"))
+ #expect(query.contains("SELECT * FROM person"))
+ #expect(query.contains("ORDER BY name DESC, id ASC"))
+ #expect(query.contains("LIMIT 100 START 200"))
+ }
+
+ @Test("A query with no scope omits USE")
+ func noScope() {
+ let query = SurrealQueryBuilder.browse(
+ table: "person", scope: SurrealScope(namespace: nil, database: nil),
+ sortColumns: [], limit: 10, offset: 0
+ )
+ #expect(!query.contains("USE"))
+ #expect(query.hasPrefix("SELECT * FROM person"))
+ }
+
+ @Test("A hostile filter value never escapes its literal")
+ func injection() {
+ let hostile = "x'; REMOVE TABLE person; --"
+ let query = SurrealQueryBuilder.filtered(
+ table: "person", scope: scope,
+ filters: [(column: "name", op: "=", value: hostile)],
+ logicMode: "and", sortColumns: [], limit: 10, offset: 0
+ )
+ #expect(query.contains("name = 'x\\'; REMOVE TABLE person; --'"))
+ #expect(!query.contains(hostile), "the quote must be escaped so the payload cannot close the literal")
+ }
+
+ @Test("Filter operators map onto SurrealQL")
+ func operators() {
+ func clause(_ op: String, _ value: String) -> String {
+ SurrealQueryBuilder.whereClause(
+ filters: [(column: "c", op: op, value: value)], logicMode: "and"
+ ) ?? ""
+ }
+ #expect(clause("=", "5") == "c = 5")
+ #expect(clause(">", "5") == "c > 5")
+ #expect(clause("=", "abc") == "c = 'abc'")
+ #expect(clause("=", "true") == "c = true")
+ #expect(clause("IS NULL", "") == "(c = NONE OR c = NULL)")
+ #expect(clause("CONTAINS", "x") == "string::contains( c, 'x')")
+ #expect(clause("STARTS WITH", "x") == "string::starts_with( c, 'x')")
+ #expect(clause("IN", "1, 2") == "c INSIDE [1, 2]")
+ }
+
+ @Test("Only a real table:id shape becomes a record literal; look-alikes stay strings")
+ func literalRecordDisambiguation() {
+ func clause(_ value: String) -> String {
+ SurrealQueryBuilder.whereClause(
+ filters: [(column: "c", op: "=", value: value)], logicMode: "and"
+ ) ?? ""
+ }
+ #expect(clause("person:tobie") == "c = person:tobie")
+ #expect(clause("12:30") == "c = '12:30'", "a time-shaped value is a string, not a record")
+ #expect(clause("1e5") == "c = 1e5")
+ #expect(clause("null") == "c = NULL")
+ #expect(clause("none") == "c = NONE")
+ #expect(clause("plain") == "c = 'plain'")
+ }
+
+ @Test("A hostile filter value cannot escape its literal in any branch")
+ func literalBranchesContainPayloads() {
+ func clause(_ value: String) -> String {
+ SurrealQueryBuilder.whereClause(
+ filters: [(column: "c", op: "=", value: value)], logicMode: "and"
+ ) ?? ""
+ }
+ // String branch: the closing quote is escaped.
+ #expect(clause("x'; REMOVE TABLE person; --") == "c = 'x\\'; REMOVE TABLE person; --'")
+ // Record branch: the id part is backtick-quoted, so ; stays inside the identifier.
+ let recordish = clause("person:a;REMOVE")
+ #expect(recordish.hasPrefix("c = person:`") && recordish.hasSuffix("`"))
+ }
+
+ @Test("Count uses GROUP ALL")
+ func count() {
+ let query = SurrealQueryBuilder.count(table: "person", scope: scope, filters: [], logicMode: "and")
+ #expect(query.contains("SELECT count() AS total FROM person GROUP ALL;"))
+ }
+}
+
+@Suite("SurrealDB - field kinds across 2.x and 3.x")
+struct SurrealFieldKindTests {
+ @Test("Optional fields parse on both versions")
+ func optionals() {
+ let legacy = SurrealFieldKind.parse("option")
+ let modern = SurrealFieldKind.parse("none | int")
+ #expect(legacy.base == .int)
+ #expect(legacy.isOptional)
+ #expect(modern.base == .int)
+ #expect(modern.isOptional)
+
+ let plain = SurrealFieldKind.parse("int")
+ #expect(plain.base == .int)
+ #expect(!plain.isOptional)
+ }
+
+ @Test("Record links expose their target table")
+ func records() {
+ #expect(SurrealFieldKind.parse("record").isRecordLink)
+ #expect(SurrealFieldKind.parse("record").recordTable == "person")
+ #expect(SurrealFieldKind.parse("none | record").isRecordLink)
+ #expect(SurrealFieldKind.parse("option>").recordTable == "person")
+ }
+
+ @Test("Structured and scalar kinds")
+ func kinds() {
+ #expect(SurrealFieldKind.parse("array").base == .array)
+ #expect(SurrealFieldKind.parse("object").base == .object)
+ #expect(SurrealFieldKind.parse("decimal").base == .decimal)
+ #expect(SurrealFieldKind.parse("geometry").base == .geometry)
+ #expect(SurrealFieldKind.parse("").base == .any)
+ }
+
+ @Test("A kind can be inferred from a typed value read back from the server")
+ func inferFromValue() {
+ #expect(SurrealFieldKind.infer(from: .int(1))?.base == .int)
+ #expect(SurrealFieldKind.infer(from: .bool(true))?.base == .bool)
+ #expect(SurrealFieldKind.infer(from: .decimal("1.5"))?.base == .decimal)
+ #expect(SurrealFieldKind.infer(from: .datetime(seconds: 0, nanoseconds: 0))?.base == .datetime)
+ #expect(SurrealFieldKind.infer(from: .recordId(SurrealRecordID(table: "t", id: .int(1))))?.base == .record)
+ // NULL/NONE carry no type, so they must not overwrite a learned kind.
+ #expect(SurrealFieldKind.infer(from: .null) == nil)
+ #expect(SurrealFieldKind.infer(from: .none) == nil)
+ }
+}
+
+@Suite("SurrealDB - INFO parsing across 2.x and 3.x")
+struct SurrealInfoParserTests {
+ @Test("Table list reads schemafull on 3.x and full on 2.x")
+ func schemafullFlag() {
+ let modern = SurrealValue.object([(key: "tables", value: .array([
+ .object([
+ (key: "name", value: .string("pf")),
+ (key: "schemafull", value: .bool(true)),
+ (key: "kind", value: .object([(key: "kind", value: .string("NORMAL"))])),
+ ]),
+ ]))])
+ let legacy = SurrealValue.object([(key: "tables", value: .array([
+ .object([
+ (key: "name", value: .string("pf")),
+ (key: "full", value: .bool(true)),
+ (key: "kind", value: .object([(key: "kind", value: .string("NORMAL"))])),
+ ]),
+ ]))])
+
+ #expect(SurrealInfoParser.tables(from: modern).first?.isSchemafull == true)
+ #expect(SurrealInfoParser.tables(from: legacy).first?.isSchemafull == true)
+ }
+
+ @Test("Relation tables are detected")
+ func relations() {
+ let value = SurrealValue.object([(key: "tables", value: .array([
+ .object([
+ (key: "name", value: .string("follows")),
+ (key: "schemafull", value: .bool(false)),
+ (key: "kind", value: .object([(key: "kind", value: .string("RELATION"))])),
+ ]),
+ ]))])
+ #expect(SurrealInfoParser.tables(from: value).first?.isRelation == true)
+ }
+
+ @Test("Nested array fields are dropped on both versions")
+ func nestedFields() {
+ #expect(SurrealInfoParser.isNestedFieldName("tags.*"))
+ #expect(SurrealInfoParser.isNestedFieldName("tags[*]"))
+ #expect(!SurrealInfoParser.isNestedFieldName("tags"))
+
+ let value = SurrealValue.object([(key: "fields", value: .array([
+ .object([(key: "name", value: .string("tags")), (key: "kind", value: .string("array"))]),
+ .object([(key: "name", value: .string("tags.*")), (key: "kind", value: .string("string"))]),
+ .object([(key: "name", value: .string("tags[*]")), (key: "kind", value: .string("string"))]),
+ ]))])
+ let columns = SurrealInfoParser.columns(from: value, isRelation: false)
+ #expect(columns.map(\.name) == ["id", "tags"])
+ #expect(columns.first?.isPrimaryKey == true)
+ }
+
+ @Test("Relation tables pin in and out after id")
+ func edgeColumns() {
+ let value = SurrealValue.object([(key: "fields", value: .array([
+ .object([(key: "name", value: .string("since")), (key: "kind", value: .string("datetime"))]),
+ ]))])
+ let columns = SurrealInfoParser.columns(from: value, isRelation: true)
+ #expect(columns.map(\.name) == ["id", "in", "out", "since"])
+ }
+
+ @Test("Index cols read as a 3.x array and a 2.x string")
+ func indexes() {
+ let modern = SurrealValue.object([(key: "indexes", value: .array([
+ .object([
+ (key: "name", value: .string("pf_nm")),
+ (key: "cols", value: .array([.string("nm")])),
+ (key: "index", value: .string("UNIQUE")),
+ ]),
+ ]))])
+ let legacy = SurrealValue.object([(key: "indexes", value: .array([
+ .object([
+ (key: "name", value: .string("pf_nm")),
+ (key: "cols", value: .string("nm")),
+ (key: "index", value: .string("UNIQUE")),
+ ]),
+ ]))])
+
+ #expect(SurrealInfoParser.indexes(from: modern).first?.columns == ["nm"])
+ #expect(SurrealInfoParser.indexes(from: legacy).first?.columns == ["nm"])
+ #expect(SurrealInfoParser.indexes(from: modern).first?.isUnique == true)
+ }
+}
+
+@Suite("SurrealDB - row flattening")
+struct SurrealRowFlattenerTests {
+ @Test("Columns are the union of top-level keys, id first")
+ func union() {
+ let rows = SurrealValue.array([
+ .object([
+ (key: "n", value: .int(1)),
+ (key: "id", value: .recordId(SurrealRecordID(table: "t", id: .string("a")))),
+ ]),
+ .object([
+ (key: "id", value: .recordId(SurrealRecordID(table: "t", id: .string("b")))),
+ (key: "other", value: .string("x")),
+ ]),
+ ])
+ let flattened = SurrealRowFlattener.flatten(rows)
+ #expect(flattened.columns == ["id", "n", "other"])
+ #expect(flattened.rows.count == 2)
+ }
+
+ @Test("Ragged rows leave sparse cells empty, not broken")
+ func ragged() {
+ let rows = SurrealValue.array([
+ .object([(key: "a", value: .int(1))]),
+ .object([(key: "b", value: .int(2))]),
+ ])
+ let flattened = SurrealRowFlattener.flatten(rows)
+ #expect(flattened.columns == ["a", "b"])
+ #expect(flattened.rows[0][1] == .null)
+ #expect(flattened.rows[1][0] == .null)
+ }
+
+ @Test("Nested values become compact JSON text")
+ func nested() {
+ let rows = SurrealValue.array([
+ .object([(key: "meta", value: .object([(key: "k", value: .int(1))]))]),
+ ])
+ let flattened = SurrealRowFlattener.flatten(rows)
+ #expect(flattened.rows[0][0] == .text(#"{"k":1}"#))
+ }
+}
+
+@Suite("SurrealDB - statement generation")
+struct SurrealStatementGeneratorTests {
+ private let scope = SurrealScope(namespace: "ns", database: "db")
+ private let columns = ["id", "name", "age"]
+ private let kinds: [String: SurrealFieldKind] = [
+ "name": SurrealFieldKind.parse("string"),
+ "age": SurrealFieldKind.parse("option"),
+ ]
+
+ private func decoded(_ cell: PluginCellValue) -> SurrealValue {
+ SurrealCellCoder.value(from: cell)
+ }
+
+ @Test("An update sets only the changed cells and never replaces the record")
+ func update() throws {
+ let change = PluginRowChange(
+ rowIndex: 0,
+ type: .update,
+ cellChanges: [(columnIndex: 2, columnName: "age", oldValue: .text("30"), newValue: .text("31"))],
+ originalRow: [.text("person:alice"), .text("Alice"), .text("30")]
+ )
+ let statements = SurrealStatementGenerator.statements(
+ table: "person", scope: scope, columns: columns, kinds: kinds,
+ changes: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: []
+ )
+ let statement = try #require(statements.first)
+
+ #expect(statement.statement.contains("UPDATE $p0 SET age = $p1;"))
+ #expect(!statement.statement.contains("CONTENT"))
+ #expect(!statement.statement.contains("MERGE"))
+ #expect(statement.statement.contains("USE NS ns DB db;"))
+
+ #expect(decoded(statement.parameters[0])
+ == .recordId(SurrealRecordID(table: "person", id: .string("alice"))))
+ #expect(decoded(statement.parameters[1]) == .int(31), "an int column must bind as an int, not a string")
+ }
+
+ @Test("The record id is bound, never interpolated")
+ func boundRecordId() throws {
+ let change = PluginRowChange(
+ rowIndex: 0,
+ type: .update,
+ cellChanges: [(columnIndex: 1, columnName: "name", oldValue: .text("a"), newValue: .text("'; REMOVE TABLE person; --"))],
+ originalRow: [.text("person:alice"), .text("a"), .null]
+ )
+ let statements = SurrealStatementGenerator.statements(
+ table: "person", scope: scope, columns: columns, kinds: kinds,
+ changes: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: []
+ )
+ let statement = try #require(statements.first)
+ #expect(!statement.statement.contains("REMOVE TABLE"))
+ #expect(decoded(statement.parameters[1]) == .string("'; REMOVE TABLE person; --"))
+ }
+
+ @Test("An insert omits a blank id so the server mints one")
+ func insert() throws {
+ let statements = SurrealStatementGenerator.statements(
+ table: "person", scope: scope, columns: columns, kinds: kinds,
+ changes: [], insertedRowData: [0: [.text(""), .text("Carol"), .text("22")]],
+ deletedRowIndices: [], insertedRowIndices: [0]
+ )
+ let statement = try #require(statements.first)
+ #expect(statement.statement.contains("CREATE person SET name = $p0, age = $p1;"))
+ #expect(decoded(statement.parameters[1]) == .int(22))
+ }
+
+ @Test("An insert with an explicit id binds it as a record id")
+ func insertWithId() throws {
+ let statements = SurrealStatementGenerator.statements(
+ table: "person", scope: scope, columns: columns, kinds: kinds,
+ changes: [], insertedRowData: [0: [.text("person:carol"), .text("Carol"), .null]],
+ deletedRowIndices: [], insertedRowIndices: [0]
+ )
+ let statement = try #require(statements.first)
+ #expect(statement.statement.contains("CREATE $p0 SET name = $p1;"))
+ #expect(decoded(statement.parameters[0])
+ == .recordId(SurrealRecordID(table: "person", id: .string("carol"))))
+ }
+
+ @Test("A delete addresses the record directly")
+ func delete() throws {
+ let change = PluginRowChange(
+ rowIndex: 0, type: .delete, cellChanges: [],
+ originalRow: [.text("person:alice"), .text("Alice"), .text("30")]
+ )
+ let statements = SurrealStatementGenerator.statements(
+ table: "person", scope: scope, columns: columns, kinds: kinds,
+ changes: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: []
+ )
+ let statement = try #require(statements.first)
+ #expect(statement.statement.contains("DELETE $p0;"))
+ #expect(statement.parameters.count == 1)
+ }
+
+ @Test("The auto-id marker on insert lets the server mint the id")
+ func autoDefaultInsertId() throws {
+ let statements = SurrealStatementGenerator.statements(
+ table: "person", scope: scope, columns: columns, kinds: kinds,
+ changes: [], insertedRowData: [0: [.text("__DEFAULT__"), .text("Carol"), .text("22")]],
+ deletedRowIndices: [], insertedRowIndices: [0]
+ )
+ let statement = try #require(statements.first)
+ #expect(statement.statement.contains("CREATE person SET"))
+ #expect(!statement.statement.contains("__DEFAULT__"))
+ #expect(!statement.statement.contains("person:__DEFAULT__"))
+ }
+
+ @Test("The auto-id marker on an updated field is skipped, never written literally")
+ func autoDefaultUpdateField() {
+ let change = PluginRowChange(
+ rowIndex: 0,
+ type: .update,
+ cellChanges: [(columnIndex: 1, columnName: "name", oldValue: .text("Alice"), newValue: .text("__DEFAULT__"))],
+ originalRow: [.text("person:alice"), .text("Alice"), .text("30")]
+ )
+ let statements = SurrealStatementGenerator.statements(
+ table: "person", scope: scope, columns: columns, kinds: kinds,
+ changes: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: []
+ )
+ #expect(statements.isEmpty, "an all-default update produces no statement, not a literal write")
+ }
+
+ @Test("The id column is never written")
+ func immutableId() {
+ let change = PluginRowChange(
+ rowIndex: 0,
+ type: .update,
+ cellChanges: [(columnIndex: 0, columnName: "id", oldValue: .text("person:a"), newValue: .text("person:b"))],
+ originalRow: [.text("person:a"), .text("Alice"), .null]
+ )
+ let statements = SurrealStatementGenerator.statements(
+ table: "person", scope: scope, columns: columns, kinds: kinds,
+ changes: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: []
+ )
+ #expect(statements.isEmpty)
+ }
+}
+
+@Suite("SurrealDB - cell coding")
+struct SurrealCellCoderTests {
+ @Test("Text coerces to the column's declared type")
+ func typed() {
+ func value(_ text: String, _ kind: String) -> SurrealValue {
+ SurrealCellCoder.value(from: .text(text), kind: SurrealFieldKind.parse(kind))
+ }
+ #expect(value("31", "int") == .int(31))
+ #expect(value("1.5", "float") == .double(1.5))
+ #expect(value("12.345", "decimal") == .decimal("12.345"))
+ #expect(value("true", "bool") == .bool(true))
+ #expect(value("31", "string") == .string("31"))
+ #expect(value("person:alice", "record")
+ == .recordId(SurrealRecordID(table: "person", id: .string("alice"))))
+ #expect(value(#"{"a":1}"#, "object") == .object([(key: "a", value: .int(1))]))
+ #expect(value("2024-09-15T12:34:56.789Z", "datetime")
+ == .datetime(seconds: 1_726_403_696, nanoseconds: 789_000_000))
+ #expect(value("1h30m", "duration") == .duration(seconds: 5400, nanoseconds: 0))
+ }
+
+ @Test("With no known kind, numeric and bool text is typed, not left a string")
+ func fallbackInference() {
+ func value(_ text: String) -> SurrealValue {
+ SurrealCellCoder.value(from: .text(text), kind: nil)
+ }
+ #expect(value("31") == .int(31))
+ #expect(value("true") == .bool(true))
+ #expect(value("false") == .bool(false))
+ #expect(value("hello") == .string("hello"))
+ #expect(value(#"{"a":1}"#) == .object([(key: "a", value: .int(1))]))
+ // A leading-zero string is not an int, so it stays a string.
+ #expect(value("007") == .string("007"))
+ }
+
+ @Test("An empty cell in an optional column becomes NONE")
+ func empties() {
+ #expect(SurrealCellCoder.value(from: .null, kind: SurrealFieldKind.parse("option")) == .none)
+ #expect(SurrealCellCoder.value(from: .text(""), kind: SurrealFieldKind.parse("option")) == .none)
+ #expect(SurrealCellCoder.value(from: .text(""), kind: SurrealFieldKind.parse("string")) == .string(""))
+ }
+
+ @Test("Parameters survive the PluginCellValue boundary")
+ func parameters() {
+ let values: [SurrealValue] = [
+ .recordId(SurrealRecordID(table: "t", id: .int(1))),
+ .int(42),
+ .decimal("1.5"),
+ .none,
+ ]
+ for value in values {
+ #expect(SurrealCellCoder.value(from: SurrealCellCoder.parameter(value)) == value)
+ }
+ }
+
+ @Test("Plain text and raw bytes are not mistaken for encoded parameters")
+ func rawCells() {
+ #expect(SurrealCellCoder.value(from: .text("hello")) == .string("hello"))
+ #expect(SurrealCellCoder.value(from: .bytes(Data([0x01, 0x02]))) == .bytes(Data([0x01, 0x02])))
+ #expect(SurrealCellCoder.value(from: .null) == .null)
+ }
+}
+
+@Suite("SurrealDB - connection config")
+struct SurrealDBConnectionConfigTests {
+ private func config(_ level: String, namespace: String = "ns", extra: [String: String] = [:]) -> SurrealDBConnectionConfig {
+ var fields = ["sdbAuthLevel": level]
+ fields.merge(extra) { _, new in new }
+ return SurrealDBConnectionConfig(config: DriverConnectionConfig(
+ host: "localhost", port: 8000, username: "root", password: "secret",
+ database: namespace, ssl: SSLConfiguration(), additionalFields: fields
+ ))
+ }
+
+ @Test("Each auth level demands the fields it needs")
+ func validation() throws {
+ try config("root").validate()
+ #expect(throws: SurrealDBError.self) { try config("namespace", namespace: "").validate() }
+ try config("namespace").validate()
+ #expect(throws: SurrealDBError.self) { try config("database").validate() }
+ try config("database", extra: ["sdbDatabase": "db"]).validate()
+ #expect(throws: SurrealDBError.self) { try config("record", extra: ["sdbDatabase": "db"]).validate() }
+ try config("record", extra: ["sdbDatabase": "db", "sdbAccess": "user"]).validate()
+ #expect(throws: SurrealDBError.self) { try config("token").validate() }
+ try config("token", extra: ["sdbToken": "jwt"]).validate()
+ }
+
+ @Test("SurrealDB 1.x is rejected, 2.x and 3.x are supported")
+ func versionGate() {
+ #expect(SurrealServerVersion.parse("surrealdb/3.2.1")?.major == 3)
+ #expect(SurrealServerVersion.parse("surrealdb-2.6.5")?.major == 2)
+ #expect(SurrealServerVersion.isSupported("surrealdb-2.6.5"))
+ #expect(SurrealServerVersion.isSupported("surrealdb/3.2.1"))
+ #expect(!SurrealServerVersion.isSupported("surrealdb-1.5.6"))
+ }
+
+ @Test("The endpoint follows the TLS setting")
+ func endpoint() {
+ #expect(config("root").baseURL?.absoluteString == "http://localhost:8000")
+
+ let secure = SurrealDBConnectionConfig(config: DriverConnectionConfig(
+ host: "db.example.com", port: 443, username: "u", password: "p",
+ database: "ns", ssl: SSLConfiguration(mode: .required), additionalFields: [:]
+ ))
+ #expect(secure.baseURL?.absoluteString == "https://db.example.com:443")
+ #expect(secure.skipTLSVerify, "Required without CA verification must not verify the certificate")
+
+ let verified = SurrealDBConnectionConfig(config: DriverConnectionConfig(
+ host: "db.example.com", port: 443, username: "u", password: "p",
+ database: "ns", ssl: SSLConfiguration(mode: .verifyIdentity), additionalFields: [:]
+ ))
+ #expect(!verified.skipTLSVerify)
+ }
+}
diff --git a/TableProTests/Plugins/SurrealDBMetadataTests.swift b/TableProTests/Plugins/SurrealDBMetadataTests.swift
new file mode 100644
index 000000000..860a523cc
--- /dev/null
+++ b/TableProTests/Plugins/SurrealDBMetadataTests.swift
@@ -0,0 +1,84 @@
+//
+// SurrealDBMetadataTests.swift
+// TableProTests
+//
+
+import Foundation
+import TableProPluginKit
+@testable import TablePro
+import Testing
+
+@Suite("SurrealDB - registry metadata")
+@MainActor
+struct SurrealDBMetadataTests {
+ private var snapshot: PluginMetadataSnapshot? {
+ PluginMetadataRegistry.shared.snapshot(forTypeId: "SurrealDB")
+ }
+
+ @Test("SurrealDB is offered before the plugin is installed")
+ func seeded() throws {
+ let snapshot = try #require(snapshot)
+ #expect(snapshot.displayName == "SurrealDB")
+ #expect(snapshot.defaultPort == 8_000)
+ #expect(snapshot.iconName == "surrealdb-icon")
+ #expect(snapshot.isDownloadable)
+ #expect(DatabaseType.allKnownTypes.contains(.surrealdb))
+ }
+
+ @Test("A SurrealDB namespace maps to the database level and a database to the schema level")
+ func hierarchy() throws {
+ let snapshot = try #require(snapshot)
+ #expect(snapshot.schema.containerEntityName == "Namespace")
+ #expect(snapshot.schema.schemaEntityName == "Database")
+ #expect(snapshot.schema.databaseGroupingStrategy == .bySchema)
+ #expect(snapshot.supportsDatabaseSwitching)
+ #expect(snapshot.capabilities.supportsSchemaSwitching)
+ }
+
+ @Test("The record id is the primary key and cannot be edited")
+ func recordIdentity() throws {
+ let snapshot = try #require(snapshot)
+ #expect(snapshot.schema.defaultPrimaryKeyColumn == "id")
+ #expect(snapshot.schema.immutableColumns == ["id"])
+ }
+
+ @Test("The schema entity name defaults to Schema for every other driver")
+ func schemaEntityNameDefault() {
+ #expect(PluginManager.shared.schemaEntityName(for: .postgresql) == "Schema")
+ #expect(PluginManager.shared.schemaEntityName(for: .surrealdb) == "Database")
+ }
+
+ @Test("The connection form and the plugin bundle declare the same fields")
+ func connectionFieldsMatch() throws {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = .sortedKeys
+
+ let seeded = try encoder.encode(surrealDBConnectionFields())
+ let declared = try encoder.encode(surrealDBPluginConnectionFields())
+
+ #expect(
+ seeded == declared,
+ "The app seed and the plugin bundle must declare identical connection fields"
+ )
+ }
+
+ @Test("The token field hides the built-in password, and nothing else does")
+ func passwordHiding() {
+ let fields = surrealDBPluginConnectionFields()
+ let token = fields.first { $0.id == "sdbToken" }
+ #expect(token?.hidesPassword == true)
+ #expect(token?.visibleWhen?.values == ["token"])
+ #expect(fields.filter(\.hidesPassword).count == 1)
+ }
+
+ @Test("The built-in database field is relabeled to Namespace, so there is no duplicate Database field")
+ func namespaceIsTheBuiltInField() throws {
+ // The top container is the namespace; the built-in database field carries it and is labeled from here.
+ #expect(try #require(snapshot).schema.containerEntityName == "Namespace")
+
+ // The custom Database field must not sit in the Connection section, or it collides with the built-in field.
+ let database = try #require(surrealDBPluginConnectionFields().first { $0.id == "sdbDatabase" })
+ #expect(database.section == .authentication)
+ #expect(database.visibleWhen?.values == ["database", "record"])
+ }
+}
diff --git a/docs/databases/surrealdb.mdx b/docs/databases/surrealdb.mdx
new file mode 100644
index 000000000..8cb2acc62
--- /dev/null
+++ b/docs/databases/surrealdb.mdx
@@ -0,0 +1,78 @@
+---
+title: SurrealDB
+description: Connect to SurrealDB with namespace and database browsing, SurrealQL, and inline record editing
+---
+
+# SurrealDB Connections
+
+TablePro connects to SurrealDB 2.x and 3.x over the HTTP RPC protocol. Browse namespaces and databases in the sidebar, run SurrealQL in the editor, and edit records inline in the data grid.
+
+The driver talks CBOR, so record links, datetimes, durations, decimals, and UUIDs keep their types instead of arriving as plain strings.
+
+## Quick Setup
+
+Click **New Connection**, select **SurrealDB**, enter the host and port (default `8000`), pick an auth level, and connect. The plugin installs on first use, or install it from **Settings** > **Plugins** > **Browse** > **SurrealDB Driver**.
+
+## Namespaces and databases
+
+SurrealDB nests tables under a namespace and a database. TablePro maps that straight onto the sidebar: namespaces sit at the top level, databases sit inside them, and tables sit inside a database. Switch either level without reconnecting.
+
+Set the namespace in the **Namespace** field when you create the connection. Leave **Database** empty to pick one from the sidebar after connecting.
+
+## Authentication
+
+SurrealDB cannot infer which level your credentials belong to, so pick the one that matches the user you are signing in as.
+
+| Auth Level | Use it for | Needs |
+|-------|-------------|-------------|
+| **Root** | A root user, defined with `DEFINE USER ... ON ROOT` | Username, password |
+| **Namespace** | A namespace user | Username, password, namespace |
+| **Database** | A database user | Username, password, namespace, database |
+| **Record Access** | A record user, defined with `DEFINE ACCESS ... TYPE RECORD` | Namespace, database, access method, and the signin fields |
+| **Token** | A JWT you already hold, including SurrealDB Cloud | The token |
+
+Only a root user can list every namespace. With namespace or database credentials, TablePro browses the scope your user is limited to.
+
+## Connection Settings
+
+### Required Fields
+
+| Field | Description |
+|-------|-------------|
+| **Host** | Server host, e.g. `localhost` |
+| **Port** | HTTP port, default `8000` |
+| **Auth Level** | Root, Namespace, Database, Record Access, or Token |
+
+### Optional Fields
+
+| Field | Description |
+|-------|-------------|
+| **Namespace** | The namespace to open. Required for every level except Root and Token |
+| **Database** | The database to open. Required for Database and Record Access; otherwise pick one from the sidebar |
+| **SSL** | Enable to connect over HTTPS |
+| **Skip TLS Verification** | Trust a self-signed certificate (Advanced section) |
+| **Token** | A JWT, shown when Auth Level is Token |
+| **Access Method** | The name from `DEFINE ACCESS`, shown when Auth Level is Record Access |
+
+## Editing records
+
+The grid writes one field at a time. Changing a cell runs an `UPDATE` that sets only the fields you touched, so computed `FUTURE` fields and fields another writer changed are left alone.
+
+- `id` is the primary key and is read-only. Clear it on a new row to let SurrealDB generate one.
+- A record link shows as `table:id`. Objects and arrays show as compact JSON and are edited as JSON.
+- On a `RELATION` table, `in` and `out` sit next to `id`.
+
+Values are sent as typed parameters, so an `int` column stays an `int` and a filter value is never treated as SurrealQL.
+
+## Schemaless tables
+
+A `SCHEMALESS` table has no declared fields. TablePro reads the columns from the rows it fetched, so a field only some records carry still gets a column, and records missing it show an empty cell.
+
+## Limitations
+
+- Transactions are not available. Each request is independent over HTTP, so `BEGIN` in one request cannot affect another. Run `BEGIN TRANSACTION; ... COMMIT TRANSACTION;` as a single query in the editor instead.
+- The structure editor is read-only. Create and change fields and indexes with `DEFINE FIELD` and `DEFINE INDEX` in the editor.
+- Live queries (`LIVE SELECT`) are not supported.
+- Range values display but cannot be edited.
+- The editor treats SurrealQL as plain text, without syntax coloring.
+- SurrealDB 1.x is not supported.
diff --git a/docs/docs.json b/docs/docs.json
index 37e3c423e..148787298 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -53,7 +53,8 @@
"databases/redis",
"databases/cassandra",
"databases/etcd",
- "databases/elasticsearch"
+ "databases/elasticsearch",
+ "databases/surrealdb"
]
},
{
diff --git a/docs/index.mdx b/docs/index.mdx
index b009498f7..979b380fe 100644
--- a/docs/index.mdx
+++ b/docs/index.mdx
@@ -80,6 +80,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under
| Snowflake | N/A (API-based) | Plugin |
| libSQL / Turso | N/A (API-based) | Plugin |
| Elasticsearch | 9200 | Plugin |
+| SurrealDB | 8000 | Plugin |
## System Requirements
diff --git a/scripts/release-all-plugins.sh b/scripts/release-all-plugins.sh
index 13ebdaec4..55cb93322 100755
--- a/scripts/release-all-plugins.sh
+++ b/scripts/release-all-plugins.sh
@@ -40,6 +40,7 @@ PLUGINS=(
snowflake
libsql
elasticsearch
+ surrealdb
)
BUNDLED_PLUGINS=(