Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/plugin-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,21 @@
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/etcd"
},
"firebase": {
"target": "FirebaseDriverPlugin",
"bundleName": "FirebaseDriverPlugin",
"bundleId": "com.TablePro.FirebaseDriverPlugin",
"bundled": false,
"displayName": "Firebase Driver",
"summary": "Cloud Firestore and Firebase Realtime Database driver over their REST APIs",
"databaseTypeIds": [
"Firestore",
"Firebase Realtime Database"
],
"icon": "firebase-icon",
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/firebase"
},
"hana": {
"target": "HanaDriver",
"bundleName": "HanaDriver",
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Reason shown in Settings > Appearance for a theme file that could not be loaded.
- Setting in Settings > General to hide the version and Check for Updates on the Welcome window.
- Install Parquet… in the Export dialog's format menu when the Parquet plugin is not installed.
- Cloud Firestore and Firebase Realtime Database as a registry plugin over their REST APIs. (#2504)

### Changed

Expand Down
11 changes: 11 additions & 0 deletions Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ let package = Package(
.library(name: "TableProGoogleCloud", targets: ["TableProGoogleCloud"]),
.library(name: "TableProSpannerCore", targets: ["TableProSpannerCore"]),
.library(name: "TableProWeaviateCore", targets: ["TableProWeaviateCore"]),
.library(name: "TableProFirebaseCore", targets: ["TableProFirebaseCore"]),
.library(name: "TableProNumberFormatting", targets: ["TableProNumberFormatting"]),
.library(name: "TableProDocumentPath", targets: ["TableProDocumentPath"]),
.library(name: "TableProJavaScriptText", targets: ["TableProJavaScriptText"]),
Expand Down Expand Up @@ -151,6 +152,11 @@ let package = Package(
dependencies: [],
path: "Sources/TableProWeaviateCore"
),
.target(
name: "TableProFirebaseCore",
dependencies: [],
path: "Sources/TableProFirebaseCore"
),
.target(
name: "TableProR2SQLCore",
dependencies: [],
Expand Down Expand Up @@ -301,6 +307,11 @@ let package = Package(
dependencies: ["TableProSpannerCore", "TableProGoogleCloud"],
path: "Tests/TableProSpannerCoreTests"
),
.testTarget(
name: "TableProFirebaseCoreTests",
dependencies: ["TableProFirebaseCore"],
path: "Tests/TableProFirebaseCoreTests"
),
.testTarget(
name: "TableProWeaviateCoreTests",
dependencies: ["TableProWeaviateCore"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public enum SQLDDLFallbackPolicy {
"SurrealDB",
"DynamoDB",
"Beancount",
"Firestore",
"Firebase Realtime Database",
]

/// True when `DROP`/`TRUNCATE` built by the app is something this engine could run.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
public static let kafka = DatabaseType(rawValue: "Kafka")
public static let cloudflareR2SQL = DatabaseType(rawValue: "Cloudflare R2 SQL")
public static let weaviate = DatabaseType(rawValue: "Weaviate")
public static let firestore = DatabaseType(rawValue: "Firestore")
public static let firebaseRealtimeDatabase = DatabaseType(rawValue: "Firebase Realtime Database")

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

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

public enum FirebaseFieldID {
public static let authMethod = "fbAuthMethod"
public static let serviceAccountJson = "fbServiceAccountJson"
public static let projectId = "fbProjectId"
public static let firestoreDatabase = "fbDatabaseId"
public static let databaseURL = "fbDatabaseURL"
public static let emulatorHost = "fbEmulatorHost"
public static let driverVariant = "driverVariant"
}

public enum FirebaseVariant: String, Sendable {
case firestore
case realtimeDatabase
}

public enum FirebaseAuthMethod: String, Sendable, CaseIterable {
case serviceAccount
case applicationDefault = "adc"
case emulator
}

public struct FirebaseConnectionSettings: Sendable, Equatable {
public static let defaultFirestoreDatabase = "(default)"
public static let firestoreEndpoint = URL(string: "https://firestore.googleapis.com/v1/")

public let variant: FirebaseVariant
public let authMethod: FirebaseAuthMethod
public let projectId: String?
public let firestoreDatabase: String
public let databaseURL: URL?
public let emulatorHost: String?

public init(
variant: FirebaseVariant,
authMethod: FirebaseAuthMethod,
projectId: String?,
firestoreDatabase: String = FirebaseConnectionSettings.defaultFirestoreDatabase,
databaseURL: URL? = nil,
emulatorHost: String? = nil
) {
self.variant = variant
self.authMethod = authMethod
self.projectId = projectId
self.firestoreDatabase = firestoreDatabase
self.databaseURL = databaseURL
self.emulatorHost = emulatorHost
}

/// `credentialProject` stands in for an empty Project ID, so a key alone is enough to connect.
public static func parse(fields: [String: String], credentialProject: String? = nil) throws -> FirebaseConnectionSettings {
let variant: FirebaseVariant = fields[FirebaseFieldID.driverVariant] == FirebaseVariant.realtimeDatabase.rawValue
? .realtimeDatabase : .firestore
let authMethod = trimmed(fields[FirebaseFieldID.authMethod]).flatMap(FirebaseAuthMethod.init(rawValue:))
?? .serviceAccount
let emulatorHost = authMethod == .emulator ? try parseEmulatorHost(fields[FirebaseFieldID.emulatorHost]) : nil
let projectId = trimmed(fields[FirebaseFieldID.projectId]) ?? credentialProject
if let projectId, !isValidProjectId(projectId) {
throw FirebaseError.configuration(
String(format: String(localized: "\"%@\" is not a Google Cloud project ID."), projectId)
)
}
let databaseURL = try trimmed(fields[FirebaseFieldID.databaseURL]).map { try parseDatabaseURL($0, emulator: authMethod == .emulator) }

switch variant {
case .firestore where projectId == nil:
throw FirebaseError.configuration(String(localized: "Enter a Project ID, or use a service account key that names one."))
case .realtimeDatabase where projectId == nil && databaseURL == nil:
throw FirebaseError.configuration(String(localized: "Enter a Database URL or a Project ID."))
default:
break
}
return FirebaseConnectionSettings(
variant: variant,
authMethod: authMethod,
projectId: projectId,
firestoreDatabase: trimmed(fields[FirebaseFieldID.firestoreDatabase]) ?? defaultFirestoreDatabase,
databaseURL: databaseURL,
emulatorHost: emulatorHost
)
}

public var firestoreBaseURL: URL? {
if let emulatorHost {
return URL(string: "http://\(emulatorHost)/v1/")
}
return Self.firestoreEndpoint
}

/// The fallback when the management API names no instance. The emulator, like the SDKs, calls
/// a project's default database `<project>-default-rtdb`.
public var defaultDatabaseURL: URL? {
if let databaseURL { return databaseURL }
guard let projectId else { return nil }
if let emulatorHost {
return URL(string: "http://\(emulatorHost)/?ns=\(projectId)-default-rtdb")
}
return URL(string: "https://\(projectId)-default-rtdb.firebaseio.com/")
}

static func isValidProjectId(_ text: String) -> Bool {
text.range(of: #"^(?:[a-z][a-z0-9.:-]{2,62})$"#, options: .regularExpression) != nil
}

/// The token goes to this host, so outside the emulator it must be one of Firebase's own.
static func parseDatabaseURL(_ text: String, emulator: Bool) throws -> URL {
guard let url = URL(string: text), let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else {
throw invalidURL(text)
}
if emulator {
guard scheme == "http" || scheme == "https" else { throw invalidURL(text) }
return url
}
let trusted = host.hasSuffix(".firebaseio.com") || host.hasSuffix(".firebasedatabase.app")
guard scheme == "https", trusted else { throw invalidURL(text) }
return URL(string: "https://\(host)/") ?? url
}

static func parseEmulatorHost(_ text: String?) throws -> String {
let value = trimmed(text)?
.replacingOccurrences(of: "http://", with: "")
.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
guard value.range(of: #"^[A-Za-z0-9.\-\[\]:]+:\d{1,5}$"#, options: .regularExpression) != nil else {
throw FirebaseError.configuration(String(localized: "Enter the emulator as host:port, such as 127.0.0.1:8080."))
}
return value
}

private static func invalidURL(_ text: String) -> FirebaseError {
.configuration(String(
format: String(localized: "\"%@\" is not a Realtime Database URL. It looks like https://<name>.firebaseio.com or https://<name>.<region>.firebasedatabase.app."),
text
))
}

static func trimmed(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil }
return value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Foundation

/// The grid only holds text, so an edit is read by the type the field already had: a string field
/// stays a string even when the new text looks like a number.
public enum FirestoreCell {
public enum Display: Sendable, Equatable {
case null
case text(String)
case bytes(Data)
}

public static func display(_ value: FirestoreValue) -> Display {
switch value {
case .null:
return .null
case .boolean(let flag):
return .text(flag ? "true" : "false")
case .integer(let number):
return .text(String(number))
case .double(let number):
return .text(doubleText(number))
case .timestamp(let text), .string(let text), .reference(let text):
return .text(text)
case .bytes(let data):
return .bytes(data)
case .geoPoint(let latitude, let longitude):
return .text("\(doubleText(latitude)), \(doubleText(longitude))")
case .array, .map:
return .text(FirestoreDocumentJSON.json(value).text())
}
}

/// A nil `typeName` reads the text as a JSON literal and falls back to a string.
public static func value(text: String, typeName: String?) throws -> FirestoreValue {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
switch typeName {
case FirestoreTypeName.string:
return .string(text)
case FirestoreTypeName.integer:
guard let number = Int64(trimmed) else { throw mismatch(text, FirestoreTypeName.integer) }
return .integer(number)
case FirestoreTypeName.double:
guard let number = Double(trimmed) else { throw mismatch(text, FirestoreTypeName.double) }
return .double(number)
case FirestoreTypeName.boolean:
switch trimmed.lowercased() {
case "true", "1": return .boolean(true)
case "false", "0": return .boolean(false)
default: throw mismatch(text, FirestoreTypeName.boolean)
}
case FirestoreTypeName.timestamp:
guard FirestoreTimestamp.isValid(trimmed) else { throw mismatch(text, FirestoreTypeName.timestamp) }
return .timestamp(trimmed)
case FirestoreTypeName.reference:
guard FirestorePath.isDocument(trimmed) else { throw mismatch(text, FirestoreTypeName.reference) }
return .reference(trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "/")))
case FirestoreTypeName.geoPoint:
return try geoPoint(trimmed, original: text)
case FirestoreTypeName.bytes:
guard let data = Data(base64Encoded: trimmed) else { throw mismatch(text, FirestoreTypeName.bytes) }
return .bytes(data)
case FirestoreTypeName.map, FirestoreTypeName.array:
return try FirestoreDocumentJSON.value(FirebaseJSON.parse(trimmed))
default:
return inferred(text)
}
}

public static func inferred(_ text: String) -> FirestoreValue {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let first = trimmed.first, "{[-0123456789tfn".contains(first),
let json = try? FirebaseJSON.parse(trimmed),
let value = try? FirestoreDocumentJSON.value(json) else {
return .string(text)
}
if case .string = value { return .string(text) }
return value
}

/// Ignores missing fields and nulls, so a column of strings with one stray null still edits as
/// strings.
public static func dominantType(_ values: [FirestoreValue?]) -> String {
var counts: [String: Int] = [:]
for case let value? in values where value != .null {
counts[value.typeName, default: 0] += 1
}
let best = counts.max { lhs, rhs in
lhs.value == rhs.value ? lhs.key > rhs.key : lhs.value < rhs.value
}
return best?.key ?? (values.contains { $0 == .null } ? FirestoreTypeName.null : FirestoreTypeName.string)
}

static func doubleText(_ value: Double) -> String {
guard value.isFinite else { return value.isNaN ? "NaN" : (value > 0 ? "Infinity" : "-Infinity") }
return "\(value)"
}

private static func geoPoint(_ trimmed: String, original: String) throws -> FirestoreValue {
let parts = trimmed.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
guard parts.count == 2, let latitude = Double(parts[0]), let longitude = Double(parts[1]),
(-90...90).contains(latitude), (-180...180).contains(longitude) else {
throw mismatch(original, FirestoreTypeName.geoPoint)
}
return .geoPoint(latitude: latitude, longitude: longitude)
}

private static func mismatch(_ text: String, _ typeName: String) -> FirebaseError {
let example: String
switch typeName {
case FirestoreTypeName.timestamp: example = "2026-10-12T08:00:00Z"
case FirestoreTypeName.reference: example = "users/alice"
case FirestoreTypeName.geoPoint: example = "45.46, 9.19"
case FirestoreTypeName.boolean: example = "true"
default: example = typeName
}
return .configuration(String(
format: String(localized: "\"%@\" is not a valid %@. Example: %@"), text, typeName, example
))
}
}
Loading
Loading