diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index 4ec1097f63..717beaebcb 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 37fcff54ed..075b676ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 8dd2d99372..2ba5fa6f0e 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -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"]), @@ -151,6 +152,11 @@ let package = Package( dependencies: [], path: "Sources/TableProWeaviateCore" ), + .target( + name: "TableProFirebaseCore", + dependencies: [], + path: "Sources/TableProFirebaseCore" + ), .target( name: "TableProR2SQLCore", dependencies: [], @@ -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"], diff --git a/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift b/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift index 64f29e5f9e..4a9d7b52be 100644 --- a/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift +++ b/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift @@ -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. diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index 15c4c17d86..16a5b6a764 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -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 @@ -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" } } diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Config/FirebaseConnectionSettings.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Config/FirebaseConnectionSettings.swift new file mode 100644 index 0000000000..84175aef2d --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Config/FirebaseConnectionSettings.swift @@ -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 `-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://.firebaseio.com or https://..firebasedatabase.app."), + text + )) + } + + static func trimmed(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } + return value + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreCellCodec.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreCellCodec.swift new file mode 100644 index 0000000000..7a911cec8f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreCellCodec.swift @@ -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 + )) + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreClient.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreClient.swift new file mode 100644 index 0000000000..efb4884497 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreClient.swift @@ -0,0 +1,198 @@ +import Foundation + +public final class FirestoreClient: @unchecked Sendable { + public let projectId: String + private let baseURL: URL + private let requester: FirebaseRequester + private let lock = NSLock() + private var database: String + + public init( + projectId: String, + database: String, + baseURL: URL, + transport: any FirebaseTransport, + credential: FirebaseCredential, + timeout: @escaping @Sendable () -> TimeInterval + ) { + self.projectId = projectId + self.database = database + self.baseURL = baseURL + self.requester = FirebaseRequester(transport: transport, credential: credential, timeout: timeout) + } + + public var currentDatabase: String { + lock.withLock { database } + } + + public func switchDatabase(to name: String) { + lock.withLock { database = name } + } + + public var documentRoot: String { + "projects/\(projectId)/databases/\(currentDatabase)/documents" + } + + // MARK: - Catalog + + /// Native-mode databases only: a Datastore-mode database answers every Firestore call with an + /// error. + public func listDatabases() async throws -> [String] { + let response = try await requester.send(method: "GET", url: try url("projects/\(projectId)/databases")) + try response.requireSuccess(String(localized: "list databases")) + let databases = try response.json()["databases"]?.arrayValue ?? [] + return databases.compactMap { database -> String? in + guard let name = database["name"]?.stringValue else { return nil } + let type = database["type"]?.stringValue ?? "FIRESTORE_NATIVE" + return type == "FIRESTORE_NATIVE" ? FirestorePath.lastSegment(name) : nil + }.sorted() + } + + public func listCollectionIds(parent: String, limit: Int = 1_000) async throws -> [String] { + var ids: [String] = [] + var pageToken: String? + repeat { + var body: [String: FirebaseJSON] = ["pageSize": .int(Int64(min(limit, 300)))] + if let pageToken { body["pageToken"] = .string(pageToken) } + let response = try await requester.send( + method: "POST", + url: try url(documentName(parent) + ":listCollectionIds"), + body: FirebaseJSON.object(body).data() + ) + try response.requireSuccess(String(format: String(localized: "list the collections of %@"), parent.isEmpty ? "/" : parent)) + let json = try response.json() + ids += (json["collectionIds"]?.arrayValue ?? []).compactMap(\.stringValue) + pageToken = json["nextPageToken"]?.stringValue + } while pageToken != nil && ids.count < limit + return ids + } + + // MARK: - Reads + + public func runQuery(parent: String, body: FirebaseJSON, operation: String) async throws -> [FirestoreDocument] { + let response = try await requester.send( + method: "POST", + url: try url(documentName(parent) + ":runQuery"), + body: body.data() + ) + try response.requireSuccess(operation) + let root = documentRoot + return try (response.json().arrayValue ?? []).compactMap { item in + guard let document = item["document"] else { return nil } + return try FirestoreDocument(rest: document, documentRoot: root) + } + } + + public func count(parent: String, body: FirebaseJSON, operation: String) async throws -> Int? { + let response = try await requester.send( + method: "POST", + url: try url(documentName(parent) + ":runAggregationQuery"), + body: body.data() + ) + try response.requireSuccess(operation) + for item in try response.json().arrayValue ?? [] { + if let total = item["result"]?["aggregateFields"]?["total"]?["integerValue"] { + switch total { + case .string(let text): return Int(text) + case .int(let value): return Int(value) + default: return nil + } + } + } + return nil + } + + public func getDocument(_ path: String) async throws -> FirestoreDocument? { + let response = try await requester.send(method: "GET", url: try url(documentName(path))) + if response.statusCode == 404 { return nil } + try response.requireSuccess(String(format: String(localized: "read %@"), path)) + return try FirestoreDocument(rest: response.json(), documentRoot: documentRoot) + } + + // MARK: - Writes + + public func createDocument( + collectionPath: String, + documentId: String?, + fields: [String: FirestoreValue] + ) async throws -> FirestoreDocument { + let split = FirestorePath.split(collectionPath: collectionPath) + var query: [URLQueryItem] = [] + if let documentId, !documentId.isEmpty { + query.append(URLQueryItem(name: "documentId", value: documentId)) + } + let root = documentRoot + let response = try await requester.send( + method: "POST", + url: try url(documentName(split.parent) + "/" + Self.encodedPathSegment(split.collectionId), query: query), + body: FirebaseJSON.object(["fields": .object(fields.mapValues { $0.rest(documentRoot: root) })]).data() + ) + try response.requireSuccess(String(format: String(localized: "create a document in %@"), collectionPath)) + return try FirestoreDocument(rest: response.json(), documentRoot: root) + } + + /// A masked field missing from `fields` is removed; without a mask the whole document is + /// replaced. + public func patchDocument( + _ path: String, + fields: [String: FirestoreValue], + mask: [String]?, + updateTime: String? = nil, + mustExist: Bool = true + ) async throws { + var query = (mask ?? []).map { URLQueryItem(name: "updateMask.fieldPaths", value: FirestoreQueryBuilder.fieldPath($0)) } + if let updateTime { + query.append(URLQueryItem(name: "currentDocument.updateTime", value: updateTime)) + } else if mustExist { + query.append(URLQueryItem(name: "currentDocument.exists", value: "true")) + } + let root = documentRoot + let response = try await requester.send( + method: "PATCH", + url: try url(documentName(path), query: query), + body: FirebaseJSON.object(["fields": .object(fields.mapValues { $0.rest(documentRoot: root) })]).data() + ) + try response.requireSuccess(String(format: String(localized: "write %@"), path)) + } + + public func deleteDocument(_ path: String) async throws { + let response = try await requester.send(method: "DELETE", url: try url(documentName(path))) + try response.requireSuccess(String(format: String(localized: "delete %@"), path)) + } + + public func ping() async throws { + _ = try await listCollectionIds(parent: "", limit: 1) + } + + // MARK: - URLs + + func documentName(_ path: String) -> String { + let segments = path.split(separator: "/", omittingEmptySubsequences: true).map { Self.encodedPathSegment(String($0)) } + let root = "projects/\(Self.encodedSegment(projectId))/databases/\(Self.encodedSegment(currentDatabase))/documents" + return segments.isEmpty ? root : root + "/" + segments.joined(separator: "/") + } + + func url(_ path: String, query: [URLQueryItem] = []) throws -> URL { + guard var components = URLComponents(string: baseURL.absoluteString + path) else { + throw FirebaseError.configuration(String(format: String(localized: "\"%@\" is not a valid path."), path)) + } + components.percentEncodedQuery = FirebaseRequester.queryString(query) + guard let url = components.url else { + throw FirebaseError.configuration(String(format: String(localized: "\"%@\" is not a valid path."), path)) + } + return url + } + + /// A domain-scoped project ID such as `example.com:shop` keeps its `:`. + static func encodedSegment(_ segment: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/?#") + return segment.addingPercentEncoding(withAllowedCharacters: allowed) ?? segment + } + + /// A `:` left raw in the last segment reads as a custom method, so `users/uid:1` would call + /// method `1` on `users/uid`. + static func encodedPathSegment(_ segment: String) -> String { + encodedSegment(segment).replacingOccurrences(of: ":", with: "%3A") + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreDocument.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreDocument.swift new file mode 100644 index 0000000000..f810394b4c --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreDocument.swift @@ -0,0 +1,66 @@ +import Foundation + +public struct FirestoreDocument: Sendable, Equatable { + /// Relative to `.../documents`, such as `users/alice`. + public let path: String + public let fields: [String: FirestoreValue] + public let updateTime: String? + + public init(path: String, fields: [String: FirestoreValue], updateTime: String? = nil) { + self.path = path + self.fields = fields + self.updateTime = updateTime + } + + public init(rest json: FirebaseJSON, documentRoot: String) throws { + guard let name = json["name"]?.stringValue else { + throw FirebaseError.malformedResponse(String(localized: "Firestore sent a document with no name.")) + } + let fields = json["fields"]?.objectValue ?? [:] + self.path = FirestorePath.relative(name, documentRoot: documentRoot) + self.fields = try fields.mapValues { try FirestoreValue(rest: $0, documentRoot: documentRoot) } + self.updateTime = json["updateTime"]?.stringValue + } +} + +/// A field a document lacks is reported absent, not null. +public struct FirestoreRowSet: Sendable, Equatable { + public let columns: [String] + public let columnTypes: [String] + public let rows: [[FirestoreCell.Display]] + public let locators: [String] + public let absentCells: [Int: Set] + /// Keyed by document path, so an edit is typed by the value it replaces rather than by the + /// column's majority. + public let cellTypes: [String: [String: String]] + + public init(documents: [FirestoreDocument], target: FirestoreCollectionTarget) { + let fieldNames = Set(documents.flatMap { $0.fields.keys }).sorted() + let columns = [FirestoreQueryBuilder.nameField] + fieldNames + var rows: [[FirestoreCell.Display]] = [] + var absent: [Int: Set] = [:] + var cellTypes: [String: [String: String]] = [:] + rows.reserveCapacity(documents.count) + for (rowIndex, document) in documents.enumerated() { + var row: [FirestoreCell.Display] = [.text(target.rowName(forDocumentPath: document.path))] + for (offset, name) in fieldNames.enumerated() { + if let value = document.fields[name] { + row.append(FirestoreCell.display(value)) + } else { + row.append(.null) + absent[rowIndex, default: []].insert(offset + 1) + } + } + rows.append(row) + cellTypes[document.path] = document.fields.mapValues(\.typeName) + } + self.columns = columns + self.columnTypes = [FirestoreTypeName.string] + fieldNames.map { name in + FirestoreCell.dominantType(documents.map { $0.fields[name] }) + } + self.rows = rows + self.locators = documents.map(\.path) + self.absentCells = absent + self.cellTypes = cellTypes + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreDocumentJSON.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreDocumentJSON.swift new file mode 100644 index 0000000000..4a6ef31fe0 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreDocumentJSON.swift @@ -0,0 +1,144 @@ +import Foundation + +/// Types JSON has no word for are single-key objects such as `{"$timestamp": "..."}`. `$double` +/// marks a double with no fraction, and `$map` wraps a real map whose only key is one of the tags. +public enum FirestoreDocumentJSON { + static let timestampTag = "$timestamp" + static let referenceTag = "$ref" + static let geoTag = "$geo" + static let bytesTag = "$bytes" + static let doubleTag = "$double" + static let mapTag = "$map" + static let tags: Set = [timestampTag, referenceTag, geoTag, bytesTag, doubleTag, mapTag] + + public static func json(_ value: FirestoreValue) -> FirebaseJSON { + switch value { + case .null: + return .null + case .boolean(let flag): + return .bool(flag) + case .integer(let number): + return .int(number) + case .double(let number): + guard number.isFinite, number.rounded() != number else { + return .object([doubleTag: number.isFinite ? .double(number) : .string(doubleWord(number))]) + } + return .double(number) + case .timestamp(let text): + return .object([timestampTag: .string(text)]) + case .string(let text): + return .string(text) + case .bytes(let data): + return .object([bytesTag: .string(data.base64EncodedString())]) + case .reference(let path): + return .object([referenceTag: .string(path)]) + case .geoPoint(let latitude, let longitude): + return .object([geoTag: .object(["latitude": .double(latitude), "longitude": .double(longitude)])]) + case .array(let values): + return .array(values.map(json)) + case .map(let fields): + let members = fields.mapValues(json) + if members.count == 1, let key = members.keys.first, tags.contains(key) { + return .object([mapTag: .object(members)]) + } + return .object(members) + } + } + + public static func text(fields: [String: FirestoreValue]) -> String { + FirebaseJSON.object(fields.mapValues(json)).text(pretty: true) + } + + public static func value(_ json: FirebaseJSON) throws -> FirestoreValue { + switch json { + case .null: + return .null + case .bool(let flag): + return .boolean(flag) + case .int(let number): + return .integer(number) + case .double(let number): + return .double(number) + case .string(let text): + return .string(text) + case .array(let values): + return .array(try values.map(value)) + case .object(let members): + if members.count == 1, let (key, payload) = members.first, tags.contains(key) { + return try tagged(key, payload) + } + return .map(try members.mapValues(value)) + } + } + + public static func fields(from text: String) throws -> [String: FirestoreValue] { + let json = try FirebaseJSON.parse(text) + guard case .object(let members) = json else { + throw FirebaseError.configuration(String(localized: "A Firestore document is a JSON object of fields.")) + } + return try members.mapValues(value) + } + + private static func tagged(_ tag: String, _ payload: FirebaseJSON) throws -> FirestoreValue { + switch tag { + case timestampTag: + guard let text = payload.stringValue, FirestoreTimestamp.isValid(text) else { + throw invalid(tag, String(localized: "an RFC 3339 time such as 2026-10-12T08:00:00Z")) + } + return .timestamp(text) + case referenceTag: + guard let path = payload.stringValue, FirestorePath.isDocument(path) else { + throw invalid(tag, String(localized: "a document path such as users/alice")) + } + return .reference(path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + case geoTag: + guard let latitude = number(payload["latitude"]), let longitude = number(payload["longitude"]), + (-90...90).contains(latitude), (-180...180).contains(longitude) else { + throw invalid(tag, String(localized: "an object with latitude and longitude")) + } + return .geoPoint(latitude: latitude, longitude: longitude) + case bytesTag: + guard let text = payload.stringValue, let data = Data(base64Encoded: text) else { + throw invalid(tag, String(localized: "base64 text")) + } + return .bytes(data) + case doubleTag: + if let number = number(payload) { return .double(number) } + switch payload.stringValue { + case "NaN": return .double(.nan) + case "Infinity": return .double(.infinity) + case "-Infinity": return .double(-.infinity) + default: throw invalid(tag, String(localized: "a number")) + } + default: + guard case .object(let members) = payload else { + throw invalid(tag, String(localized: "an object")) + } + return .map(try members.mapValues(value)) + } + } + + private static func number(_ json: FirebaseJSON?) -> Double? { + switch json { + case .double(let value): return value + case .int(let value): return Double(value) + default: return nil + } + } + + private static func doubleWord(_ value: Double) -> String { + value.isNaN ? "NaN" : (value > 0 ? "Infinity" : "-Infinity") + } + + private static func invalid(_ tag: String, _ expected: String) -> FirebaseError { + .configuration(String(format: String(localized: "%@ needs %@."), tag, expected)) + } +} + +public enum FirestoreTimestamp { + private static let pattern = #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$"# + + public static func isValid(_ text: String) -> Bool { + text.range(of: pattern, options: .regularExpression) != nil + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreQuery.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreQuery.swift new file mode 100644 index 0000000000..661a9db8a4 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreQuery.swift @@ -0,0 +1,369 @@ +import Foundation + +public struct FirebaseFilterSpec: Codable, Sendable, Equatable { + public let column: String + public let op: String + public let value: String + public let secondValue: String? + + public init(column: String, op: String, value: String, secondValue: String? = nil) { + self.column = column + self.op = op + self.value = value + self.secondValue = secondValue + } + + /// The host sends BETWEEN as `value: "lower,upper"` with the upper bound again in `secondValue`, + /// so the lower bound is `value` without that suffix, whatever commas the bounds hold. + public var betweenBounds: (lower: String, upper: String)? { + guard let upper = secondValue else { + let parts = value.split(separator: ",", omittingEmptySubsequences: false).map(String.init) + return parts.count == 2 ? (parts[0], parts[1]) : nil + } + let suffix = Array(("," + upper).unicodeScalars) + let scalars = Array(value.unicodeScalars) + guard scalars.count >= suffix.count, Array(scalars.suffix(suffix.count)) == suffix else { + return (value, upper) + } + var lower = String.UnicodeScalarView() + lower.append(contentsOf: scalars.dropLast(suffix.count)) + return (String(lower), upper) + } +} + +public struct FirebaseSortSpec: Codable, Sendable, Equatable { + public let column: String + public let ascending: Bool + + public init(column: String, ascending: Bool) { + self.column = column + self.ascending = ascending + } +} + +/// Turned into a query only when it runs, because filter values are typed by the fields the +/// collection was last seen holding. +public struct FirebaseBrowseSpec: Codable, Sendable, Equatable { + public let table: String + public let filters: [FirebaseFilterSpec] + public let logicMode: String + public let sorts: [FirebaseSortSpec] + public let limit: Int + public let offset: Int + + public init( + table: String, + filters: [FirebaseFilterSpec] = [], + logicMode: String = "AND", + sorts: [FirebaseSortSpec] = [], + limit: Int, + offset: Int + ) { + self.table = table + self.filters = filters + self.logicMode = logicMode + self.sorts = sorts + self.limit = limit + self.offset = offset + } + + public var usesOr: Bool { + logicMode.uppercased() == "OR" + } +} + +/// A table in the sidebar is either a collection path (`users`, `users/alice/orders`) or a +/// collection group, `*/orders`, which is every `orders` collection at any depth. +public struct FirestoreCollectionTarget: Sendable, Equatable { + public static let groupPrefix = "*/" + + public let parentPath: String + public let collectionId: String + public let isGroup: Bool + + public init(table: String) throws { + let trimmed = table.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) + if trimmed.hasPrefix(Self.groupPrefix) { + let id = String(trimmed.dropFirst(Self.groupPrefix.count)) + guard !id.isEmpty, !id.contains("/") else { throw Self.invalid(table) } + self.parentPath = "" + self.collectionId = id + self.isGroup = true + return + } + guard !trimmed.isEmpty, !FirestorePath.isDocument(trimmed) else { throw Self.invalid(table) } + let split = FirestorePath.split(collectionPath: trimmed) + self.parentPath = split.parent + self.collectionId = split.collectionId + self.isGroup = false + } + + public var collectionPath: String { + parentPath.isEmpty ? collectionId : parentPath + "/" + collectionId + } + + public static func groupName(_ collectionId: String) -> String { + groupPrefix + collectionId + } + + /// The `__name__` cell of a row: the document ID in a collection, the whole path in a group, + /// where IDs repeat across parents. + public func rowName(forDocumentPath path: String) -> String { + isGroup ? path : FirestorePath.lastSegment(path) + } + + public func documentPath(forRowName name: String) -> String { + if isGroup || name.contains("/") { + return name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + return collectionPath + "/" + name + } + + private static func invalid(_ table: String) -> FirebaseError { + .configuration(String(format: String(localized: "\"%@\" is not a collection path."), table)) + } +} + +public enum FirestoreQueryBuilder { + public static let nameField = "__name__" + static let unsupportedOperator = String( + localized: "Firestore cannot filter with %@. It supports =, !=, <, <=, >, >=, IN, NOT IN, BETWEEN, STARTS WITH, IS NULL, and CONTAINS on an array field." + ) + public static let rawFilterColumn = "__RAW__" + + /// `typeOf` answers the type a field was last seen holding, so `zip = 30` compares against the + /// string "30" when zip is a string field. + public static func structuredQuery( + spec: FirebaseBrowseSpec, + target: FirestoreCollectionTarget, + documentRoot: String, + typeOf: (String) -> String? + ) throws -> FirebaseJSON { + var query = try baseQuery(spec: spec, target: target, documentRoot: documentRoot, typeOf: typeOf) + if spec.offset > 0 { query["offset"] = .int(Int64(spec.offset)) } + if spec.limit > 0 { query["limit"] = .int(Int64(spec.limit)) } + return .object(["structuredQuery": .object(query)]) + } + + /// Keeps the order because ordering by a field drops the documents without it, and the count + /// has to match the rows the grid shows. + public static func countQuery( + spec: FirebaseBrowseSpec, + target: FirestoreCollectionTarget, + documentRoot: String, + typeOf: (String) -> String? + ) throws -> FirebaseJSON { + let query = try baseQuery(spec: spec, target: target, documentRoot: documentRoot, typeOf: typeOf) + return .object(["structuredAggregationQuery": .object([ + "structuredQuery": .object(query), + "aggregations": .array([.object(["alias": .string("total"), "count": .object([:])])]) + ])]) + } + + private static func baseQuery( + spec: FirebaseBrowseSpec, + target: FirestoreCollectionTarget, + documentRoot: String, + typeOf: (String) -> String? + ) throws -> [String: FirebaseJSON] { + var query: [String: FirebaseJSON] = [ + "from": .array([.object([ + "collectionId": .string(target.collectionId), + "allDescendants": .bool(target.isGroup) + ])]) + ] + let filters = try spec.filters.map { filter in + try self.filter(filter, target: target, documentRoot: documentRoot, typeOf: typeOf) + } + if let combined = combine(filters, op: spec.usesOr ? "OR" : "AND") { + query["where"] = combined + } + if !spec.sorts.isEmpty { + query["orderBy"] = .array(spec.sorts.map { sort in + .object([ + "field": .object(["fieldPath": .string(fieldPath(sort.column))]), + "direction": .string(sort.ascending ? "ASCENDING" : "DESCENDING") + ]) + }) + } + return query + } + + public static func sortedFields(_ spec: FirebaseBrowseSpec) -> [String] { + spec.sorts.map(\.column).filter { $0 != nameField } + } + + static func combine(_ filters: [FirebaseJSON], op: String) -> FirebaseJSON? { + switch filters.count { + case 0: return nil + case 1: return filters[0] + default: + return .object(["compositeFilter": .object(["op": .string(op), "filters": .array(filters)])]) + } + } + + static func filter( + _ filter: FirebaseFilterSpec, + target: FirestoreCollectionTarget, + documentRoot: String, + typeOf: (String) -> String? + ) throws -> FirebaseJSON { + if filter.column == rawFilterColumn { + return try rawFilter(filter.value) + } + let path = fieldPath(filter.column) + let columnType = typeOf(filter.column) + let typed: (String) throws -> FirebaseJSON = { text in + try operand(text, column: filter.column, typeName: columnType, target: target, documentRoot: documentRoot) + } + switch filter.op.uppercased() { + case "=": + return field(path, "EQUAL", try typed(filter.value)) + case "!=", "<>": + return field(path, "NOT_EQUAL", try typed(filter.value)) + case ">": + return field(path, "GREATER_THAN", try typed(filter.value)) + case ">=": + return field(path, "GREATER_THAN_OR_EQUAL", try typed(filter.value)) + case "<": + return field(path, "LESS_THAN", try typed(filter.value)) + case "<=": + return field(path, "LESS_THAN_OR_EQUAL", try typed(filter.value)) + case "IN", "NOT IN": + let items = try listItems(filter.value).map(typed) + guard !items.isEmpty, items.count <= 30 else { + throw FirebaseError.unsupported(String(localized: "Firestore compares IN and NOT IN against 1 to 30 values.")) + } + let op = filter.op.uppercased() == "IN" ? "IN" : "NOT_IN" + return field(path, op, .object(["arrayValue": .object(["values": .array(items)])])) + case "BETWEEN": + guard let bounds = filter.betweenBounds else { + throw FirebaseError.configuration(String(localized: "BETWEEN needs two values.")) + } + return .object(["compositeFilter": .object(["op": .string("AND"), "filters": .array([ + field(path, "GREATER_THAN_OR_EQUAL", try typed(bounds.lower)), + field(path, "LESS_THAN_OR_EQUAL", try typed(bounds.upper)) + ])])]) + case "STARTS WITH": + return .object(["compositeFilter": .object(["op": .string("AND"), "filters": .array([ + field(path, "GREATER_THAN_OR_EQUAL", .object(["stringValue": .string(filter.value)])), + field(path, "LESS_THAN", .object(["stringValue": .string(filter.value + "\u{F8FF}")])) + ])])]) + case "CONTAINS" where columnType == FirestoreTypeName.array: + let element = FirestoreCell.inferred(filter.value).rest(documentRoot: documentRoot) + return field(path, "ARRAY_CONTAINS", element) + case "IS NULL": + return unary(path, "IS_NULL") + case "IS NOT NULL": + return unary(path, "IS_NOT_NULL") + default: + throw FirebaseError.unsupported(String(format: unsupportedOperator, filter.op)) + } + } + + /// Field names outside `[A-Za-z_][A-Za-z0-9_]*` go in backticks, the way the API spells them. + public static func fieldPath(_ column: String) -> String { + if column == nameField { return column } + let simple = column.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil + if simple { return column } + let escaped = column.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "`", with: "\\`") + return "`" + escaped + "`" + } + + /// An update mask as a request line carries it: top-level field names, comma separated, each + /// in backticks when it holds anything but letters, digits and `_`. + public static func maskText(_ fields: [String]) -> String { + fields.map(fieldPath).joined(separator: ",") + } + + public static func maskFields(_ text: String) -> [String] { + var fields: [String] = [] + var current = "" + var quoted = false + var escaping = false + var wasQuoted = false + func finish() { + let name = wasQuoted ? current : current.trimmingCharacters(in: .whitespaces) + if !name.isEmpty { fields.append(name) } + current = "" + wasQuoted = false + } + for character in text { + if escaping { + current.append(character) + escaping = false + } else if quoted { + switch character { + case "\\": escaping = true + case "`": quoted = false + default: current.append(character) + } + } else { + switch character { + case "`": + quoted = true + wasQuoted = true + current = "" + case ",": + finish() + default: + current.append(character) + } + } + } + finish() + return fields + } + + private static func operand( + _ text: String, + column: String, + typeName: String?, + target: FirestoreCollectionTarget, + documentRoot: String + ) throws -> FirebaseJSON { + if column == nameField { + let path = target.documentPath(forRowName: text) + return .object(["referenceValue": .string(FirestorePath.absolute(path, documentRoot: documentRoot))]) + } + return try FirestoreCell.value(text: text, typeName: typeName).rest(documentRoot: documentRoot) + } + + private static func field(_ path: String, _ op: String, _ value: FirebaseJSON) -> FirebaseJSON { + .object(["fieldFilter": .object([ + "field": .object(["fieldPath": .string(path)]), + "op": .string(op), + "value": value + ])]) + } + + private static func unary(_ path: String, _ op: String) -> FirebaseJSON { + .object(["unaryFilter": .object([ + "field": .object(["fieldPath": .string(path)]), + "op": .string(op) + ])]) + } + + /// The raw filter row takes a REST `Filter` object as written in the Firestore API reference, + /// which is the only way to reach a nested field or a composite the filter bar cannot draw. + private static func rawFilter(_ text: String) throws -> FirebaseJSON { + let json: FirebaseJSON + do { + json = try FirebaseJSON.parse(text) + } catch { + throw FirebaseError.configuration(String(localized: "A raw Firestore filter is a JSON Filter object, such as {\"fieldFilter\": {...}}.")) + } + guard let members = json.objectValue, members.count == 1, + let key = members.keys.first, ["fieldFilter", "unaryFilter", "compositeFilter"].contains(key) else { + throw FirebaseError.configuration(String(localized: "A raw Firestore filter is a JSON Filter object, such as {\"fieldFilter\": {...}}.")) + } + return json + } + + static func listItems(_ text: String) -> [String] { + if let json = try? FirebaseJSON.parse(text), case .array(let values) = json { + return values.map { value in value.stringValue ?? value.text() } + } + return text.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreValue.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreValue.swift new file mode 100644 index 0000000000..0f0c5ce6fb --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Firestore/FirestoreValue.swift @@ -0,0 +1,201 @@ +import Foundation + +public indirect enum FirestoreValue: Sendable, Hashable { + case null + case boolean(Bool) + case integer(Int64) + case double(Double) + /// RFC 3339 text exactly as the server sent it, so microseconds survive a round trip. + case timestamp(String) + case string(String) + case bytes(Data) + /// A document path relative to `.../documents`, such as `users/alice`. + case reference(String) + case geoPoint(latitude: Double, longitude: Double) + case array([FirestoreValue]) + case map([String: FirestoreValue]) + + public var typeName: String { + switch self { + case .null: return FirestoreTypeName.null + case .boolean: return FirestoreTypeName.boolean + case .integer: return FirestoreTypeName.integer + case .double: return FirestoreTypeName.double + case .timestamp: return FirestoreTypeName.timestamp + case .string: return FirestoreTypeName.string + case .bytes: return FirestoreTypeName.bytes + case .reference: return FirestoreTypeName.reference + case .geoPoint: return FirestoreTypeName.geoPoint + case .array: return FirestoreTypeName.array + case .map: return FirestoreTypeName.map + } + } +} + +public enum FirestoreTypeName { + public static let null = "null" + public static let boolean = "boolean" + public static let integer = "integer" + public static let double = "double" + public static let timestamp = "timestamp" + public static let string = "string" + public static let bytes = "bytes" + public static let reference = "reference" + public static let geoPoint = "geopoint" + public static let array = "array" + public static let map = "map" + + public static let all = [string, integer, double, boolean, timestamp, map, array, reference, geoPoint, bytes, null] +} + +// MARK: - REST wire format + +public extension FirestoreValue { + /// `documentRoot` is `projects/

/databases//documents`, which a reference carries in full + /// on the wire and which the grid leaves off. + init(rest json: FirebaseJSON, documentRoot: String) throws { + guard case .object(let members) = json, let (key, payload) = members.first, members.count == 1 else { + throw FirebaseError.malformedResponse(String(localized: "Firestore sent a value with no type.")) + } + switch key { + case "nullValue": + self = .null + case "booleanValue": + guard case .bool(let value) = payload else { throw Self.malformed(key) } + self = .boolean(value) + case "integerValue": + self = .integer(try Self.int64(payload, key: key)) + case "doubleValue": + self = .double(try Self.double(payload, key: key)) + case "timestampValue": + guard let text = payload.stringValue else { throw Self.malformed(key) } + self = .timestamp(text) + case "stringValue": + guard let text = payload.stringValue else { throw Self.malformed(key) } + self = .string(text) + case "bytesValue": + guard let text = payload.stringValue, let data = Data(base64Encoded: text) else { + throw Self.malformed(key) + } + self = .bytes(data) + case "referenceValue": + guard let text = payload.stringValue else { throw Self.malformed(key) } + self = .reference(FirestorePath.relative(text, documentRoot: documentRoot)) + case "geoPointValue": + self = .geoPoint( + latitude: try Self.double(payload["latitude"] ?? .double(0), key: key), + longitude: try Self.double(payload["longitude"] ?? .double(0), key: key) + ) + case "arrayValue": + let values = payload["values"]?.arrayValue ?? [] + self = .array(try values.map { try FirestoreValue(rest: $0, documentRoot: documentRoot) }) + case "mapValue": + let fields = payload["fields"]?.objectValue ?? [:] + self = .map(try fields.mapValues { try FirestoreValue(rest: $0, documentRoot: documentRoot) }) + default: + throw FirebaseError.malformedResponse( + String(format: String(localized: "Firestore sent a value of unknown type %@."), key) + ) + } + } + + func rest(documentRoot: String) -> FirebaseJSON { + switch self { + case .null: + return .object(["nullValue": .null]) + case .boolean(let value): + return .object(["booleanValue": .bool(value)]) + case .integer(let value): + return .object(["integerValue": .string(String(value))]) + case .double(let value): + return .object(["doubleValue": Self.restDouble(value)]) + case .timestamp(let text): + return .object(["timestampValue": .string(text)]) + case .string(let text): + return .object(["stringValue": .string(text)]) + case .bytes(let data): + return .object(["bytesValue": .string(data.base64EncodedString())]) + case .reference(let path): + return .object(["referenceValue": .string(FirestorePath.absolute(path, documentRoot: documentRoot))]) + case .geoPoint(let latitude, let longitude): + return .object(["geoPointValue": .object([ + "latitude": .double(latitude), "longitude": .double(longitude) + ])]) + case .array(let values): + return .object(["arrayValue": .object([ + "values": .array(values.map { $0.rest(documentRoot: documentRoot) }) + ])]) + case .map(let fields): + return .object(["mapValue": .object([ + "fields": .object(fields.mapValues { $0.rest(documentRoot: documentRoot) }) + ])]) + } + } + + /// The REST API reads NaN and the infinities only as the strings it writes them as. + private static func restDouble(_ value: Double) -> FirebaseJSON { + guard value.isFinite else { + return .string(value.isNaN ? "NaN" : (value > 0 ? "Infinity" : "-Infinity")) + } + return .double(value) + } + + private static func int64(_ json: FirebaseJSON, key: String) throws -> Int64 { + switch json { + case .string(let text): + guard let value = Int64(text) else { throw malformed(key) } + return value + case .int(let value): + return value + default: + throw malformed(key) + } + } + + private static func double(_ json: FirebaseJSON, key: String) throws -> Double { + switch json { + case .double(let value): return value + case .int(let value): return Double(value) + case .string("NaN"): return .nan + case .string("Infinity"): return .infinity + case .string("-Infinity"): return -.infinity + default: throw malformed(key) + } + } + + private static func malformed(_ key: String) -> FirebaseError { + .malformedResponse(String(format: String(localized: "Firestore sent a malformed %@."), key)) + } +} + +public enum FirestorePath { + public static func relative(_ name: String, documentRoot: String) -> String { + let prefix = documentRoot + "/" + return name.hasPrefix(prefix) ? String(name.dropFirst(prefix.count)) : name + } + + public static func absolute(_ path: String, documentRoot: String) -> String { + let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if trimmed.hasPrefix("projects/") { + return trimmed + } + return trimmed.isEmpty ? documentRoot : documentRoot + "/" + trimmed + } + + /// A path with an even number of segments names a document, an odd number a collection. + public static func isDocument(_ path: String) -> Bool { + let segments = path.split(separator: "/", omittingEmptySubsequences: true) + return !segments.isEmpty && segments.count.isMultiple(of: 2) + } + + public static func lastSegment(_ path: String) -> String { + path.split(separator: "/").last.map(String.init) ?? path + } + + /// The parent of a collection path: the root for `users`, the document for `users/a/orders`. + public static func split(collectionPath: String) -> (parent: String, collectionId: String) { + var segments = collectionPath.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + let collectionId = segments.popLast() ?? "" + return (segments.joined(separator: "/"), collectionId) + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/RealtimeDatabase/RealtimeDatabaseClient.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/RealtimeDatabase/RealtimeDatabaseClient.swift new file mode 100644 index 0000000000..18c553c61d --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/RealtimeDatabase/RealtimeDatabaseClient.swift @@ -0,0 +1,217 @@ +import Foundation + +public final class RealtimeDatabaseClient: @unchecked Sendable { + private let requester: FirebaseRequester + private let lock = NSLock() + private var instanceURL: URL + + public init( + instanceURL: URL, + transport: any FirebaseTransport, + credential: FirebaseCredential, + timeout: @escaping @Sendable () -> TimeInterval + ) { + self.instanceURL = instanceURL + self.requester = FirebaseRequester(transport: transport, credential: credential, timeout: timeout) + } + + public var currentURL: URL { + lock.withLock { instanceURL } + } + + public func switchInstance(to url: URL) { + lock.withLock { instanceURL = url } + } + + public var instanceName: String { + Self.instanceName(of: currentURL) + } + + /// The host's first label, or the emulator's `ns`. + public static func instanceName(of url: URL) -> String { + if let namespace = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "ns" })?.value { + return namespace + } + return url.host?.split(separator: ".").first.map(String.init) ?? url.absoluteString + } + + // MARK: - Reads + + public func get(_ path: String, query: [URLQueryItem] = []) async throws -> FirebaseJSON { + let response = try await requester.send(method: "GET", url: try url(path, query: query)) + try response.requireSuccess(String(format: String(localized: "read %@"), Self.display(path))) + return try response.json() + } + + public func getWithETag(_ path: String) async throws -> (value: FirebaseJSON, etag: String?) { + let response = try await requester.send( + method: "GET", + url: try url(path), + headers: ["X-Firebase-ETag": "true"] + ) + try response.requireSuccess(String(format: String(localized: "read %@"), Self.display(path))) + return (try response.json(), response.header("ETag")) + } + + public func childKeys(_ path: String) async throws -> [String] { + let json = try await get(path, query: [URLQueryItem(name: "shallow", value: "true")]) + guard case .object(let members) = json else { return [] } + return members.keys.sorted(by: RealtimeDatabaseOrdering.keyPrecedes) + } + + public func children(_ path: String, plan: RealtimeDatabaseReadPlan) async throws -> [(key: String, value: FirebaseJSON)] { + let json = try await get(path, query: plan.queryItems) + let children: [(key: String, value: FirebaseJSON)] + switch json { + case .object(let members): + children = members.map { ($0.key, $0.value) } + case .array(let values): + children = values.enumerated().compactMap { index, value in + value == .null ? nil : (String(index), value) + } + case .null: + children = [] + default: + children = [("", json)] + } + return plan.window(RealtimeDatabaseOrdering.sort(children, order: plan.order, ascending: plan.ascending)) + } + + // MARK: - Writes + + /// The server refuses `print=silent` on a conditional write. A refused one answers 412 with the + /// node's current data as the body, which must not reach the error text. + public func put(_ path: String, value: FirebaseJSON, ifMatch: String? = nil) async throws { + var headers: [String: String] = [:] + var query = [URLQueryItem(name: "print", value: "silent")] + if let ifMatch { + headers["if-match"] = ifMatch + query = [] + } + let response = try await requester.send( + method: "PUT", + url: try url(path, query: query), + body: value.data(), + headers: headers + ) + if ifMatch != nil, response.statusCode == 412 { + throw FirebaseError.precondition(String(localized: "The node changed after it was read.")) + } + try response.requireSuccess(String(format: String(localized: "write %@"), Self.display(path))) + } + + public func patch(_ path: String, values: [String: FirebaseJSON]) async throws { + let response = try await requester.send( + method: "PATCH", + url: try url(path, query: [URLQueryItem(name: "print", value: "silent")]), + body: FirebaseJSON.object(values).data() + ) + try response.requireSuccess(String(format: String(localized: "write %@"), Self.display(path))) + } + + public func push(_ path: String, value: FirebaseJSON) async throws -> String? { + let response = try await requester.send(method: "POST", url: try url(path), body: value.data()) + try response.requireSuccess(String(format: String(localized: "write %@"), Self.display(path))) + return try response.json()["name"]?.stringValue + } + + public func delete(_ path: String) async throws { + let response = try await requester.send(method: "DELETE", url: try url(path, query: [URLQueryItem(name: "print", value: "silent")])) + try response.requireSuccess(String(format: String(localized: "delete %@"), Self.display(path))) + } + + /// Reads at most one root child and sends none of it back. A shallow read lists every root key, + /// and `shallow` cannot be combined with a limit. + public func ping() async throws { + let query = [ + URLQueryItem(name: "orderBy", value: RealtimeDatabaseReadPlan.orderParameter(.key)), + URLQueryItem(name: "limitToFirst", value: "1"), + URLQueryItem(name: "print", value: "silent") + ] + let response = try await requester.send(method: "GET", url: try url("", query: query)) + try response.requireSuccess(String(format: String(localized: "read %@"), "/")) + } + + /// An instance outside us-central1 answers its `firebaseio.com` name with a 404 naming its real + /// URL, adopted only when it is a Firebase host since the token goes wherever it points. + public func locate() async throws { + let shallow = [URLQueryItem(name: "shallow", value: "true")] + let response = try await requester.send(method: "GET", url: try url("", query: shallow)) + if response.statusCode == 404, let corrected = Self.correctURL(in: response.body) { + switchInstance(to: corrected) + try await ping() + return + } + try response.requireSuccess(String(format: String(localized: "read %@"), "/")) + } + + static func correctURL(in body: Data) -> URL? { + guard let text = (try? FirebaseJSON.parse(body))?["correctUrl"]?.stringValue else { return nil } + return try? FirebaseConnectionSettings.parseDatabaseURL(text, emulator: false) + } + + // MARK: - URLs + + func url(_ path: String, query: [URLQueryItem] = []) throws -> URL { + let base = currentURL + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { + throw FirebaseError.configuration(String(localized: "The Database URL is not valid.")) + } + let segments = RealtimeDatabasePath.normalized(path).split(separator: "/").map { segment in + String(segment).addingPercentEncoding(withAllowedCharacters: Self.segmentAllowed) ?? String(segment) + } + let basePath = components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let joined = ([basePath].filter { !$0.isEmpty } + segments).joined(separator: "/") + components.percentEncodedPath = "/" + joined + ".json" + let namespace = components.queryItems?.filter { $0.name == "ns" } ?? [] + components.percentEncodedQuery = FirebaseRequester.queryString(namespace + query) + guard let url = components.url else { + throw FirebaseError.configuration(String(format: String(localized: "\"%@\" is not a valid path."), path)) + } + return url + } + + private static let segmentAllowed: CharacterSet = { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/?#") + return allowed + }() + + static func display(_ path: String) -> String { + "/" + RealtimeDatabasePath.normalized(path) + } +} + +public struct RealtimeDatabaseInstanceLister: Sendable { + private let requester: FirebaseRequester + + public init(transport: any FirebaseTransport, credential: FirebaseCredential, timeout: @escaping @Sendable () -> TimeInterval) { + self.requester = FirebaseRequester(transport: transport, credential: credential, timeout: timeout) + } + + public func instances(projectId: String) async throws -> [URL] { + guard let url = URL(string: "https://firebasedatabase.googleapis.com/v1beta/projects/\(projectId)/locations/-/instances?pageSize=100") else { + return [] + } + var response = try await requester.send(method: "GET", url: url) + if response.statusCode == 403, Self.needsQuotaProject(response.body) { + response = try await requester.send(method: "GET", url: url, headers: ["x-goog-user-project": projectId]) + } + guard response.isSuccess else { + throw FirebaseError.from(status: response.statusCode, body: response.body, operation: String(localized: "list database instances")) + } + let instances = try response.json()["instances"]?.arrayValue ?? [] + return instances.compactMap { instance -> URL? in + guard instance["state"]?.stringValue != "DISABLED", + let text = instance["databaseUrl"]?.stringValue else { return nil } + return URL(string: text.hasSuffix("/") ? text : text + "/") + }.sorted { RealtimeDatabaseClient.instanceName(of: $0) < RealtimeDatabaseClient.instanceName(of: $1) } + } + + /// The browsed project is billed only when the API asks for a quota project, so a credential + /// that may not bill it is never refused for it. + static func needsQuotaProject(_ body: Data) -> Bool { + (FirebaseError.apiMessage(from: body) ?? "").localizedCaseInsensitiveContains("quota project") + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/RealtimeDatabase/RealtimeDatabaseQuery.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/RealtimeDatabase/RealtimeDatabaseQuery.swift new file mode 100644 index 0000000000..a553503cc2 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/RealtimeDatabase/RealtimeDatabaseQuery.swift @@ -0,0 +1,283 @@ +import Foundation + +/// The REST API has no offset, so a page asks the server for the first `offset + limit` children in +/// its order and drops the leading `offset` here. +public struct RealtimeDatabaseReadPlan: Sendable, Equatable { + public enum Order: Sendable, Equatable { + case key + case value + case child(String) + } + + public let order: Order + public let ascending: Bool + /// Already JSON-encoded, as the API takes them. + public let bounds: [String: String] + public let fetchCount: Int + public let dropCount: Int + /// The most children kept after the drop; 0 keeps them all. + public let keepCount: Int + public let rawQuery: [URLQueryItem]? + + public init( + order: Order, + ascending: Bool, + bounds: [String: String], + fetchCount: Int, + dropCount: Int, + keepCount: Int = 0, + rawQuery: [URLQueryItem]? = nil + ) { + self.order = order + self.ascending = ascending + self.bounds = bounds + self.fetchCount = fetchCount + self.dropCount = dropCount + self.keepCount = keepCount + self.rawQuery = rawQuery + } + + public var queryItems: [URLQueryItem] { + if let rawQuery { return rawQuery } + var items = [URLQueryItem(name: "orderBy", value: Self.orderParameter(order))] + for key in bounds.keys.sorted() { + items.append(URLQueryItem(name: key, value: bounds[key])) + } + if fetchCount > 0 { + items.append(URLQueryItem(name: ascending ? "limitToFirst" : "limitToLast", value: String(fetchCount))) + } + return items + } + + public func window(_ sorted: [(key: String, value: FirebaseJSON)]) -> [(key: String, value: FirebaseJSON)] { + let kept = sorted.dropFirst(dropCount) + return Array(keepCount > 0 ? kept.prefix(keepCount) : kept) + } + + static func orderParameter(_ order: Order) -> String { + switch order { + case .key: return "\"$key\"" + case .value: return "\"$value\"" + case .child(let path): return FirebaseJSON.string(path).text() + } + } + + static func order(fromParameter text: String) -> Order? { + guard case .string(let name)? = try? FirebaseJSON.parse(text) else { return nil } + switch name { + case "$key": return .key + case "$value": return .value + case "$priority": return nil + default: return .child(name) + } + } +} + +public enum RealtimeDatabaseQueryPlanner { + public static let keyColumn = "$key" + public static let valueColumn = "$value" + public static let rawFilterColumn = "__RAW__" + + public static func plan(spec: FirebaseBrowseSpec, typeOf: (String) -> String?) throws -> RealtimeDatabaseReadPlan { + let fetchCount = spec.limit > 0 ? spec.offset + spec.limit : 0 + guard spec.filters.count <= 1 else { + throw FirebaseError.unsupported(String(localized: "The Realtime Database filters on one field at a time. Remove all but one filter, or use a raw filter.")) + } + let sort = spec.sorts.first + guard spec.sorts.count <= 1 else { + throw FirebaseError.unsupported(String(localized: "The Realtime Database sorts by one column at a time.")) + } + guard let filter = spec.filters.first else { + let order = sort.map { self.order(for: $0.column) } ?? .key + return RealtimeDatabaseReadPlan( + order: order, ascending: sort?.ascending ?? true, bounds: [:], + fetchCount: fetchCount, dropCount: spec.offset + ) + } + if filter.column == rawFilterColumn { + return rawPlan(filter.value, spec: spec) + } + let order = self.order(for: filter.column) + if let sort, self.order(for: sort.column) != order { + throw FirebaseError.unsupported(String(localized: "The Realtime Database sorts only by the column it filters on.")) + } + let columnType = typeOf(filter.column) + let operand: (String) -> String = { text in + literal(text, isKey: order == .key, typeName: columnType) + } + let bounds: [String: String] + switch filter.op.uppercased() { + case "=": + bounds = ["equalTo": operand(filter.value)] + case ">=": + bounds = ["startAt": operand(filter.value)] + case ">": + bounds = ["startAfter": operand(filter.value)] + case "<=": + bounds = ["endAt": operand(filter.value)] + case "<": + bounds = ["endBefore": operand(filter.value)] + case "BETWEEN": + guard let between = filter.betweenBounds else { + throw FirebaseError.configuration(String(localized: "BETWEEN needs two values.")) + } + bounds = ["startAt": operand(between.lower), "endAt": operand(between.upper)] + case "STARTS WITH": + bounds = [ + "startAt": FirebaseJSON.string(filter.value).text(), + "endAt": FirebaseJSON.string(filter.value + "\u{F8FF}").text() + ] + case "IS NULL": + bounds = ["equalTo": "null"] + default: + throw FirebaseError.unsupported(String( + format: String(localized: "The Realtime Database cannot filter with %@. It supports =, <, <=, >, >=, BETWEEN, STARTS WITH and IS NULL."), + filter.op + )) + } + return RealtimeDatabaseReadPlan( + order: order, ascending: sort?.ascending ?? true, bounds: bounds, + fetchCount: fetchCount, dropCount: spec.offset + ) + } + + /// Rows come back in the order the raw text names before the page is cut. The API refuses a + /// limit without `orderBy`, so a key order is added only to text that names no range or limit. + static func rawPlan(_ text: String, spec: FirebaseBrowseSpec) -> RealtimeDatabaseReadPlan { + var items = FirebaseRequest.parseQuery(text) + let names = Set(items.map(\.name)) + let named = items.first { $0.name == "orderBy" }?.value + let order = named.flatMap(RealtimeDatabaseReadPlan.order(fromParameter:)) ?? .key + if spec.limit > 0, names.isDisjoint(with: rangeParameters), !names.contains("shallow") { + if named == nil { + items.append(URLQueryItem(name: "orderBy", value: RealtimeDatabaseReadPlan.orderParameter(.key))) + } + items.append(URLQueryItem(name: "limitToFirst", value: String(spec.offset + spec.limit))) + } else if spec.limit > 0, named != nil, names.isDisjoint(with: limitParameters) { + items.append(URLQueryItem(name: "limitToFirst", value: String(spec.offset + spec.limit))) + } + return RealtimeDatabaseReadPlan( + order: order, ascending: true, bounds: [:], + fetchCount: 0, dropCount: spec.offset, keepCount: spec.limit, rawQuery: items + ) + } + + static let limitParameters: Set = ["limitToFirst", "limitToLast"] + static let rangeParameters: Set = limitParameters.union(["startAt", "startAfter", "endAt", "endBefore", "equalTo"]) + + static func order(for column: String) -> RealtimeDatabaseReadPlan.Order { + switch column { + case keyColumn: return .key + case valueColumn: return .value + default: return .child(column) + } + } + + /// A child value is read by the type the column holds, so `zip = 02134` stays a string in a + /// string column. + static func literal(_ text: String, isKey: Bool, typeName: String?) -> String { + if isKey || typeName == RealtimeDatabaseTypeName.string { + return FirebaseJSON.string(text).text() + } + let trimmed = text.trimmingCharacters(in: .whitespaces) + if typeName == RealtimeDatabaseTypeName.boolean || trimmed == "true" || trimmed == "false" { + if trimmed.lowercased() == "true" { return "true" } + if trimmed.lowercased() == "false" { return "false" } + } + if typeName != RealtimeDatabaseTypeName.boolean, let number = Double(trimmed), number.isFinite { + return Int64(exactly: number).map(String.init) ?? FirebaseJSON.doubleText(number) + } + return FirebaseJSON.string(text).text() + } +} + +public enum RealtimeDatabaseTypeName { + public static let string = "string" + public static let number = "number" + public static let boolean = "boolean" + public static let object = "object" + public static let array = "array" + public static let null = "null" + + public static func of(_ json: FirebaseJSON) -> String { + switch json { + case .null: return null + case .bool: return boolean + case .int, .double: return number + case .string: return string + case .array: return array + case .object: return object + } + } +} + +/// The REST API answers a query with a JSON object, so the server's order is restored here. +public enum RealtimeDatabaseOrdering { + /// Keys that read as 32-bit integers come first in numeric order, then the rest as strings. + public static func keyPrecedes(_ lhs: String, _ rhs: String) -> Bool { + switch (Int32(lhs).flatMap { canonical(lhs) ? $0 : nil }, Int32(rhs).flatMap { canonical(rhs) ? $0 : nil }) { + case let (left?, right?): return left < right + case (.some, nil): return true + case (nil, .some): return false + case (nil, nil): return lhs < rhs + } + } + + private static func canonical(_ key: String) -> Bool { + guard let value = Int32(key) else { return false } + return String(value) == key + } + + /// null, false, true, numbers, strings, then objects; ties fall back to the key. + public static func valuePrecedes(_ lhs: (key: String, value: FirebaseJSON?), _ rhs: (key: String, value: FirebaseJSON?)) -> Bool { + let left = rank(lhs.value), right = rank(rhs.value) + if left != right { return left < right } + switch (lhs.value, rhs.value) { + case let (.int(a)?, .int(b)?) where a != b: return a < b + case let (.double(a)?, .double(b)?) where a != b: return a < b + case let (.int(a)?, .double(b)?) where Double(a) != b: return Double(a) < b + case let (.double(a)?, .int(b)?) where a != Double(b): return a < Double(b) + case let (.string(a)?, .string(b)?) where a != b: return a < b + default: return keyPrecedes(lhs.key, rhs.key) + } + } + + private static func rank(_ value: FirebaseJSON?) -> Int { + switch value { + case nil, .null?: return 0 + case .bool(false)?: return 1 + case .bool(true)?: return 2 + case .int?, .double?: return 3 + case .string?: return 4 + case .array?, .object?: return 5 + } + } + + public static func value(of child: FirebaseJSON, at order: RealtimeDatabaseReadPlan.Order) -> FirebaseJSON? { + switch order { + case .key: return nil + case .value: return child + case .child(let path): + var current: FirebaseJSON? = child + for segment in path.split(separator: "/") { + current = current?[String(segment)] + } + return current + } + } + + public static func sort( + _ children: [(key: String, value: FirebaseJSON)], + order: RealtimeDatabaseReadPlan.Order, + ascending: Bool + ) -> [(key: String, value: FirebaseJSON)] { + let sorted = children.sorted { lhs, rhs in + if order == .key { return keyPrecedes(lhs.key, rhs.key) } + return valuePrecedes( + (lhs.key, value(of: lhs.value, at: order)), + (rhs.key, value(of: rhs.value, at: order)) + ) + } + return ascending ? sorted : sorted.reversed() + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/RealtimeDatabase/RealtimeDatabaseRows.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/RealtimeDatabase/RealtimeDatabaseRows.swift new file mode 100644 index 0000000000..954dba521b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/RealtimeDatabase/RealtimeDatabaseRows.swift @@ -0,0 +1,133 @@ +import Foundation + +public struct RealtimeDatabaseRowSet: Sendable, Equatable { + public let columns: [String] + public let columnTypes: [String] + public let rows: [[String?]] + public let locators: [String] + public let absentCells: [Int: Set] + public let cellTypes: [String: [String: String]] + + public init(children: [(key: String, value: FirebaseJSON)], nodePath: String) { + let fieldNames = Set(children.flatMap { $0.value.objectValue?.keys.map { $0 } ?? [] }).sorted() + let hasPlainValues = children.contains { $0.value.objectValue == nil } + var columns = [RealtimeDatabaseQueryPlanner.keyColumn] + fieldNames + if hasPlainValues || children.isEmpty { + columns.append(RealtimeDatabaseQueryPlanner.valueColumn) + } + var rows: [[String?]] = [] + var absent: [Int: Set] = [:] + var cellTypes: [String: [String: String]] = [:] + var typeVotes: [[String: Int]] = Array(repeating: [:], count: columns.count) + for (rowIndex, child) in children.enumerated() { + let locator = RealtimeDatabasePath.join(nodePath, child.key) + var row: [String?] = [child.key.isEmpty ? RealtimeDatabasePath.nodeKey(nodePath) : child.key] + var types: [String: String] = [:] + let members = child.value.objectValue + for (offset, column) in columns.enumerated().dropFirst() { + let cell: FirebaseJSON? + if column == RealtimeDatabaseQueryPlanner.valueColumn { + cell = members == nil ? child.value : nil + } else { + cell = members?[column] + } + guard let cell else { + row.append(nil) + absent[rowIndex, default: []].insert(offset) + continue + } + row.append(Self.text(cell)) + let type = RealtimeDatabaseTypeName.of(cell) + types[column] = type + typeVotes[offset][type, default: 0] += 1 + } + rows.append(row) + cellTypes[locator] = types + } + self.columns = columns + self.columnTypes = typeVotes.enumerated().map { index, votes in + guard index > 0 else { return RealtimeDatabaseTypeName.string } + return votes.max { $0.value == $1.value ? $0.key > $1.key : $0.value < $1.value }?.key + ?? RealtimeDatabaseTypeName.string + } + self.rows = rows + self.locators = children.map { RealtimeDatabasePath.join(nodePath, $0.key) } + self.absentCells = absent + self.cellTypes = cellTypes + } + + static func text(_ value: FirebaseJSON) -> String? { + switch value { + case .null: return nil + case .string(let text): return text + case .bool(let flag): return flag ? "true" : "false" + case .int(let number): return String(number) + case .double(let number): return "\(number)" + case .array, .object: return value.text() + } + } +} + +public enum RealtimeDatabaseCell { + /// A string cell stays a string; anything else is read as a JSON literal. + public static func value(text: String, typeName: String?) throws -> FirebaseJSON { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + switch typeName { + case RealtimeDatabaseTypeName.string: + return .string(text) + case RealtimeDatabaseTypeName.number: + guard let json = try? FirebaseJSON.parse(trimmed), case .int = json else { + guard let number = Double(trimmed), number.isFinite else { + throw FirebaseError.configuration(String(format: String(localized: "\"%@\" is not a number."), text)) + } + return .double(number) + } + return json + case RealtimeDatabaseTypeName.boolean: + switch trimmed.lowercased() { + case "true", "1": return .bool(true) + case "false", "0": return .bool(false) + default: throw FirebaseError.configuration(String(format: String(localized: "\"%@\" is not true or false."), text)) + } + case RealtimeDatabaseTypeName.object, RealtimeDatabaseTypeName.array: + return try FirebaseJSON.parse(trimmed) + default: + if let first = trimmed.first, "{[-0123456789tfn\"".contains(first), let json = try? FirebaseJSON.parse(trimmed) { + return json + } + return .string(text) + } + } +} + +public enum RealtimeDatabasePath { + /// Characters the Realtime Database refuses in a key; checked before a write so the error + /// names the key instead of coming back as a bare 400. + static let forbidden = CharacterSet(charactersIn: ".#$[]/").union(.controlCharacters) + + public static func normalized(_ path: String) -> String { + path.split(separator: "/", omittingEmptySubsequences: true).joined(separator: "/") + } + + /// An empty key stands for the node itself, which is how a node holding a plain value shows. + public static func join(_ parent: String, _ key: String) -> String { + let base = normalized(parent) + guard !key.isEmpty else { return base } + return base.isEmpty ? key : base + "/" + key + } + + /// The `$key` of the row a node holding a plain value shows as. A key cannot hold `/`, so this + /// can never name a child. + public static func nodeKey(_ path: String) -> String { + "/" + normalized(path) + } + + public static func validateKey(_ key: String) throws { + guard !key.isEmpty, key.rangeOfCharacter(from: forbidden) == nil, key.utf8.count <= 768 else { + throw FirebaseError.configuration(String( + format: String(localized: "\"%@\" is not a valid key. Keys cannot be empty or contain . # $ [ ] or /."), + key + )) + } + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseError.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseError.swift new file mode 100644 index 0000000000..cd553cfe1e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseError.swift @@ -0,0 +1,106 @@ +import Foundation + +public enum FirebaseError: Error, LocalizedError, Equatable, Sendable { + case configuration(String) + case notConnected + case transport(String) + /// The credential was refused outright: expired, revoked, or for another project. + case authentication(String) + /// The credential is valid but IAM or the security rules refuse this operation. Kept apart + /// from an empty result so a blocked read never looks like a collection with no documents. + case permissionDenied(operation: String, detail: String) + case notFound(String) + case precondition(String) + case unsupported(String) + case api(status: Int, message: String) + case malformedResponse(String) + + public var errorDescription: String? { + switch self { + case .configuration(let detail), .transport(let detail), .malformedResponse(let detail), + .authentication(let detail), .notFound(let detail), .unsupported(let detail): + return detail + case .notConnected: + return String(localized: "Not connected to Firebase.") + case .permissionDenied(let operation, let detail): + let lead = String(format: String(localized: "Permission denied: %@."), operation) + return detail.isEmpty ? lead : lead + "\n" + detail + case .precondition(let detail): + return detail + case .api(_, let message): + return message + } + } + + /// `operation` names what was attempted, in the user's terms ("read users", "write /chats"), + /// because Firebase's own denial text names neither the path nor the verb. + public static func from(status: Int, body: Data, operation: String) -> FirebaseError { + let message = apiMessage(from: body) + switch status { + case 401: + return .authentication(message ?? String(localized: "Firebase refused the credential.")) + case 403: + return .permissionDenied(operation: operation, detail: permissionHint(message)) + case 404: + return .notFound(message ?? String(format: String(localized: "Not found: %@."), operation)) + case 409, 412: + return .precondition(message ?? String(localized: "The data changed after it was read.")) + case 400 where apiStatus(from: body) == "FAILED_PRECONDITION": + return .precondition(message ?? String(localized: "The data changed after it was read.")) + default: + return .api( + status: status, + message: message ?? String(format: String(localized: "Firebase returned HTTP %d."), status) + ) + } + } + + /// The Realtime Database answers a rules denial with the bare words "Permission denied", and + /// Firestore with a sentence that never mentions rules, so the hint says where to look. + static func permissionHint(_ message: String?) -> String { + let hint = String( + localized: "The credential lacks the IAM role for this, or the security rules block it." + ) + guard let message, !message.isEmpty else { return hint } + if message.lowercased().trimmingCharacters(in: .punctuationCharacters) == "permission denied" { + return hint + } + return message + "\n" + hint + } + + /// Firestore names the failure in `error.status`, which tells a stale write or a missing + /// index (`FAILED_PRECONDITION`) from a malformed request, all of them HTTP 400. + static func apiStatus(from body: Data) -> String? { + guard let json = try? FirebaseJSON.parse(body) else { return nil } + let error = json.arrayValue?.first?["error"] ?? json["error"] + return error?["status"]?.stringValue + } + + /// Firestore errors are `{"error": {"message": ...}}` and Realtime Database errors are + /// `{"error": "..."}`; both shapes are read here so the caller never sees raw JSON. + public static func apiMessage(from body: Data) -> String? { + guard let json = try? FirebaseJSON.parse(body) else { + let text = String(data: body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return (text?.isEmpty ?? true) ? nil : text + } + if case .array(let items) = json, let first = items.first { + return apiMessage(fromJSON: first) + } + return apiMessage(fromJSON: json) + } + + private static func apiMessage(fromJSON json: FirebaseJSON) -> String? { + if let error = json["error"] { + if let text = error.stringValue, !text.isEmpty { + return text + } + if let text = error["message"]?.stringValue, !text.isEmpty { + return text + } + } + if let text = json["message"]?.stringValue, !text.isEmpty { + return text + } + return nil + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseJSON.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseJSON.swift new file mode 100644 index 0000000000..3e551da1a8 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseJSON.swift @@ -0,0 +1,178 @@ +import Foundation + +/// A JSON value that keeps integers and doubles apart. Firestore stores `1` and `1.0` as different +/// types, and a Realtime Database node edited as `1` must not come back as `1.0`. +public indirect enum FirebaseJSON: Sendable, Hashable { + case null + case bool(Bool) + case int(Int64) + case double(Double) + case string(String) + case array([FirebaseJSON]) + case object([String: FirebaseJSON]) + + public static func parse(_ data: Data) throws -> FirebaseJSON { + let object: Any + do { + object = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + } catch { + throw FirebaseError.malformedResponse(String(localized: "The text is not valid JSON.")) + } + return FirebaseJSON(foundation: object) + } + + public static func parse(_ text: String) throws -> FirebaseJSON { + try parse(Data(text.utf8)) + } + + public init(foundation object: Any) { + switch object { + case is NSNull: + self = .null + case let number as NSNumber: + self = FirebaseJSON(number: number) + case let string as String: + self = .string(string) + case let array as [Any]: + self = .array(array.map(FirebaseJSON.init(foundation:))) + case let dictionary as [String: Any]: + self = .object(dictionary.mapValues(FirebaseJSON.init(foundation:))) + default: + self = .null + } + } + + private init(number: NSNumber) { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + self = .bool(number.boolValue) + return + } + switch CFNumberGetType(number) { + case .float32Type, .float64Type, .floatType, .doubleType, .cgFloatType: + self = .double(number.doubleValue) + default: + self = .int(number.int64Value) + } + } + + public var objectValue: [String: FirebaseJSON]? { + if case .object(let value) = self { return value } + return nil + } + + public var arrayValue: [FirebaseJSON]? { + if case .array(let value) = self { return value } + return nil + } + + public var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + public subscript(key: String) -> FirebaseJSON? { + objectValue?[key] + } + + /// Keys are written sorted, so two equal values always produce the same text and an edit can be + /// compared against what was fetched. + public func text(pretty: Bool = false) -> String { + var output = "" + write(into: &output, pretty: pretty, indent: 0) + return output + } + + public func data() -> Data { + Data(text().utf8) + } + + private func write(into output: inout String, pretty: Bool, indent: Int) { + switch self { + case .null: + output += "null" + case .bool(let value): + output += value ? "true" : "false" + case .int(let value): + output += String(value) + case .double(let value): + output += Self.doubleText(value) + case .string(let value): + Self.writeString(value, into: &output) + case .array(let values): + writeArray(values, into: &output, pretty: pretty, indent: indent) + case .object(let members): + writeObject(members, into: &output, pretty: pretty, indent: indent) + } + } + + private func writeArray(_ values: [FirebaseJSON], into output: inout String, pretty: Bool, indent: Int) { + guard !values.isEmpty else { + output += "[]" + return + } + output += "[" + for (index, value) in values.enumerated() { + if index > 0 { output += "," } + if pretty { output += "\n" + String(repeating: " ", count: indent + 1) } + value.write(into: &output, pretty: pretty, indent: indent + 1) + } + if pretty { output += "\n" + String(repeating: " ", count: indent) } + output += "]" + } + + private func writeObject( + _ members: [String: FirebaseJSON], + into output: inout String, + pretty: Bool, + indent: Int + ) { + guard !members.isEmpty else { + output += "{}" + return + } + output += "{" + for (index, key) in members.keys.sorted().enumerated() { + if index > 0 { output += "," } + if pretty { output += "\n" + String(repeating: " ", count: indent + 1) } + Self.writeString(key, into: &output) + output += pretty ? ": " : ":" + members[key]?.write(into: &output, pretty: pretty, indent: indent + 1) + } + if pretty { output += "\n" + String(repeating: " ", count: indent) } + output += "}" + } + + /// JSON has no NaN or infinity, so those are written as strings; the caller that needs them + /// back as numbers wraps them in a tag of its own. + static func doubleText(_ value: Double) -> String { + guard value.isFinite else { + return value.isNaN ? "\"NaN\"" : (value > 0 ? "\"Infinity\"" : "\"-Infinity\"") + } + if value.rounded() == value, abs(value) < 1e16 { + return String(format: "%.1f", value) + } + return "\(value)" + } + + static func writeString(_ value: String, into output: inout String) { + output += "\"" + for scalar in value.unicodeScalars { + switch scalar { + case "\"": output += "\\\"" + case "\\": output += "\\\\" + case "\n": output += "\\n" + case "\r": output += "\\r" + case "\t": output += "\\t" + case "\u{08}": output += "\\b" + case "\u{0C}": output += "\\f" + default: + if scalar.value < 0x20 { + output += String(format: "\\u%04x", scalar.value) + } else { + output.unicodeScalars.append(scalar) + } + } + } + output += "\"" + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseStatement.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseStatement.swift new file mode 100644 index 0000000000..7226e3f290 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseStatement.swift @@ -0,0 +1,148 @@ +import Foundation + +/// A grid page travels as a tagged spec; everything else is a request line the user can read in +/// the history and type in the editor, such as `PATCH users/alice?updateMask=age {"age": 31}`. +public enum FirebaseStatement: Sendable, Equatable { + case browse(FirebaseBrowseSpec) + case request(FirebaseRequest) + + public static let browseTag = "FIREBASE_BROWSE:" + + public static func encode(_ spec: FirebaseBrowseSpec) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = (try? encoder.encode(spec)) ?? Data() + return browseTag + data.base64EncodedString() + } + + public static func parse(_ text: String) throws -> FirebaseStatement { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix(browseTag) { + let payload = String(trimmed.dropFirst(browseTag.count)) + guard let data = Data(base64Encoded: payload), + let spec = try? JSONDecoder().decode(FirebaseBrowseSpec.self, from: data) else { + throw FirebaseError.malformedResponse(String(localized: "The browse request is damaged. Reopen the collection.")) + } + return .browse(spec) + } + return .request(try FirebaseRequest.parse(trimmed)) + } +} + +public struct FirebaseRequest: Sendable, Equatable { + public enum Method: String, Sendable, CaseIterable { + case get = "GET" + case query = "QUERY" + case put = "PUT" + case patch = "PATCH" + case post = "POST" + case delete = "DELETE" + } + + public let method: Method + public let path: String + public let query: [URLQueryItem] + public let body: String? + + public init(method: Method, path: String, query: [URLQueryItem] = [], body: String? = nil) { + self.method = method + self.path = RealtimeDatabasePath.normalized(path) + self.query = query + self.body = body + } + + public static func parse(_ text: String) throws -> FirebaseRequest { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let lines = trimmed.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + let header = lines.first.map(String.init) ?? "" + let parts = header.split(maxSplits: 2, omittingEmptySubsequences: true, whereSeparator: \.isWhitespace).map(String.init) + guard let verb = parts.first, let method = Method(rawValue: verb.uppercased()) else { + throw FirebaseError.configuration(usage) + } + var target = parts.count > 1 ? parts[1] : "" + var inlineBody = parts.count > 2 ? parts[2] : "" + if method == .query, target.hasPrefix("{") { + inlineBody = ([target, inlineBody].filter { !$0.isEmpty }).joined(separator: " ") + target = "" + } + let following = lines.count > 1 ? String(lines[1]) : "" + let body = [inlineBody, following].joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + + var path = target + var query: [URLQueryItem] = [] + if let mark = target.firstIndex(of: "?") { + path = String(target[.. [URLQueryItem] { + let trimmed = text.trimmingCharacters(in: CharacterSet(charactersIn: "?& \n")) + return trimmed.split(separator: "&").compactMap { pair in + let parts = pair.split(separator: "=", maxSplits: 1).map(String.init) + guard let name = parts.first.map(decoded), !name.isEmpty else { return nil } + return URLQueryItem(name: name, value: parts.count > 1 ? decoded(parts[1]) : "") + } + } + + public func queryValue(_ name: String) -> String? { + query.first { $0.name == name }?.value + } + + /// Escapes only what `parse` splits on, so an ID with a space, `&` or `=` reads back whole and + /// the rest of the line stays readable. + public var text: String { + var line = method.rawValue + " " + (path.isEmpty ? "/" : Self.encodedPath(path)) + if !query.isEmpty { + line += "?" + query.map { Self.escaped($0.name) + "=" + Self.escaped($0.value ?? "") }.joined(separator: "&") + } + if let body { line += " " + body } + return line + } + + static let usage = String( + localized: "Enter a request such as GET users/alice, QUERY {\"structuredQuery\": {...}}, PATCH users/alice {\"age\": 31} or DELETE users/alice." + ) + + private static let escapedScalars = CharacterSet.whitespacesAndNewlines + .union(.controlCharacters) + .union(CharacterSet(charactersIn: "%?#&=/")) + + static func escaped(_ text: String) -> String { + var result = String.UnicodeScalarView() + for scalar in text.unicodeScalars { + guard escapedScalars.contains(scalar) else { + result.append(scalar) + continue + } + for byte in String(scalar).utf8 { + result.append(contentsOf: String(format: "%%%02X", byte).unicodeScalars) + } + } + return String(result) + } + + private static func decoded(_ text: String) -> String { + text.removingPercentEncoding ?? text + } + + private static func encodedPath(_ path: String) -> String { + path.split(separator: "/").map { escaped(String($0)) }.joined(separator: "/") + } + + private static func decodedPath(_ path: String) -> String { + path.split(separator: "/").map { decoded(String($0)) }.joined(separator: "/") + } +} diff --git a/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseTransport.swift b/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseTransport.swift new file mode 100644 index 0000000000..f1c2825923 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProFirebaseCore/Wire/FirebaseTransport.swift @@ -0,0 +1,221 @@ +import Foundation + +public struct FirebaseHTTPRequest: Sendable, Equatable { + public let method: String + public let url: URL + public let headers: [String: String] + public let body: Data? + public let timeoutInterval: TimeInterval + + public init(method: String, url: URL, headers: [String: String] = [:], body: Data? = nil, timeoutInterval: TimeInterval) { + self.method = method + self.url = url + self.headers = headers + self.body = body + self.timeoutInterval = timeoutInterval + } +} + +public struct FirebaseHTTPResponse: Sendable, Equatable { + public let statusCode: Int + public let headers: [String: String] + public let body: Data + + public init(statusCode: Int, headers: [String: String] = [:], body: Data) { + self.statusCode = statusCode + self.headers = headers + self.body = body + } + + public var isSuccess: Bool { + (200..<300).contains(statusCode) + } + + public func header(_ name: String) -> String? { + headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value + } + + public func json() throws -> FirebaseJSON { + guard !body.isEmpty else { return .null } + return try FirebaseJSON.parse(body) + } + + func requireSuccess(_ operation: String) throws { + guard !isSuccess else { return } + throw FirebaseError.from(status: statusCode, body: body, operation: operation) + } +} + +public protocol FirebaseTransport: Sendable { + func send(_ request: FirebaseHTTPRequest) async throws -> FirebaseHTTPResponse + func cancelAll() +} + +/// The emulators take the fixed token `owner`, which bypasses the security rules there. +public struct FirebaseCredential: Sendable { + public let token: @Sendable () async throws -> String? + public let invalidate: @Sendable () async -> Void + public let quotaProject: String? + + public init( + token: @escaping @Sendable () async throws -> String?, + invalidate: @escaping @Sendable () async -> Void = {}, + quotaProject: String? = nil + ) { + self.token = token + self.invalidate = invalidate + self.quotaProject = quotaProject + } + + public static let emulator = FirebaseCredential(token: { "owner" }) +} + +public final class URLSessionFirebaseTransport: FirebaseTransport, @unchecked Sendable { + private let session: URLSession + private let lock = NSLock() + private var inFlight: [ObjectIdentifier: URLSessionTask] = [:] + + public init(resourceTimeout: TimeInterval) { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForResource = resourceTimeout + configuration.httpCookieStorage = nil + configuration.urlCache = nil + session = URLSession(configuration: configuration, delegate: FirebaseRedirectRefusal(), delegateQueue: nil) + } + + deinit { + session.invalidateAndCancel() + } + + public func cancelAll() { + let tasks = lock.withLock { Array(inFlight.values) } + tasks.forEach { $0.cancel() } + } + + public func send(_ request: FirebaseHTTPRequest) async throws -> FirebaseHTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method + urlRequest.httpBody = request.body + urlRequest.timeoutInterval = request.timeoutInterval + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + let tracker = FirebaseTaskTracker(transport: self) + defer { tracker.finish() } + do { + let (data, response) = try await session.data(for: urlRequest, delegate: tracker) + guard let http = response as? HTTPURLResponse else { + throw FirebaseError.transport(String(localized: "Firebase answered with something other than HTTP.")) + } + var headers: [String: String] = [:] + for (key, value) in http.allHeaderFields { + if let key = key as? String, let value = value as? String { headers[key] = value } + } + return FirebaseHTTPResponse(statusCode: http.statusCode, headers: headers, body: data) + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch let error as URLError { + throw FirebaseError.transport(error.localizedDescription) + } + } + + fileprivate func register(_ task: URLSessionTask) { + lock.withLock { inFlight[ObjectIdentifier(task)] = task } + } + + fileprivate func unregister(_ task: URLSessionTask) { + lock.withLock { inFlight[ObjectIdentifier(task)] = nil } + } +} + +/// A redirect would carry the bearer token to whatever host it names. +private final class FirebaseRedirectRefusal: NSObject, URLSessionTaskDelegate, Sendable { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest + ) async -> URLRequest? { + nil + } +} + +private final class FirebaseTaskTracker: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private weak var transport: URLSessionFirebaseTransport? + private let lock = NSLock() + private var task: URLSessionTask? + + init(transport: URLSessionFirebaseTransport) { + self.transport = transport + } + + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + lock.withLock { self.task = task } + transport?.register(task) + } + + func finish() { + guard let task = lock.withLock({ task }) else { return } + transport?.unregister(task) + } +} + +/// A 401 drops the cached token and retries once, since a token can expire between being cached +/// and being used. +struct FirebaseRequester: Sendable { + let transport: any FirebaseTransport + let credential: FirebaseCredential + let timeout: @Sendable () -> TimeInterval + + func send( + method: String, + url: URL, + body: Data? = nil, + headers: [String: String] = [:], + timeoutOverride: TimeInterval? = nil + ) async throws -> FirebaseHTTPResponse { + var response = try await attempt(method, url, body, headers, timeoutOverride) + if response.statusCode == 401 { + await credential.invalidate() + response = try await attempt(method, url, body, headers, timeoutOverride) + } + return response + } + + private func attempt( + _ method: String, + _ url: URL, + _ body: Data?, + _ extraHeaders: [String: String], + _ timeoutOverride: TimeInterval? + ) async throws -> FirebaseHTTPResponse { + var headers = extraHeaders + if let token = try await credential.token() { + headers["Authorization"] = "Bearer " + token + } + if let project = credential.quotaProject, headers["x-goog-user-project"] == nil { + headers["x-goog-user-project"] = project + } + if body != nil { + headers["Content-Type"] = "application/json" + } + try Task.checkCancellation() + return try await transport.send(FirebaseHTTPRequest( + method: method, + url: url, + headers: headers, + body: body, + timeoutInterval: timeoutOverride ?? timeout() + )) + } + + static func queryString(_ items: [URLQueryItem]) -> String? { + guard !items.isEmpty else { return nil } + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: "&=+?#") + func encoded(_ text: String) -> String { + text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text + } + return items.map { encoded($0.name) + "=" + encoded($0.value ?? "") }.joined(separator: "&") + } +} diff --git a/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleApplicationDefaultCredentials.swift b/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleApplicationDefaultCredentials.swift index e7fcc9393a..d0f0cdfe3f 100644 --- a/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleApplicationDefaultCredentials.swift +++ b/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleApplicationDefaultCredentials.swift @@ -62,6 +62,15 @@ public enum GoogleApplicationDefaultCredentials: Sendable, Equatable { } } + public var quotaProject: String? { + switch self { + case .serviceAccount: + return nil + case .authorizedUser(_, _, _, let quotaProjectId), .impersonatedServiceAccount(_, _, let quotaProjectId, _): + return quotaProjectId + } + } + private static func configuredPath(explicit: String?, environment: [String: String]) -> String? { let candidates = [explicit, environment[environmentVariable]] return candidates diff --git a/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleCredentialSource.swift b/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleCredentialSource.swift index 44c34ac449..92cf3023b0 100644 --- a/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleCredentialSource.swift +++ b/Packages/TableProCore/Sources/TableProGoogleCloud/Auth/GoogleCredentialSource.swift @@ -11,6 +11,9 @@ public enum GoogleCredentialSource: Sendable, Equatable { public struct GoogleCredentials: Sendable { public let tokenProvider: any GoogleAccessTokenProviding public let projectHint: String? + /// The project a user login bills API calls to, sent as `x-goog-user-project`. Nil for a + /// service account, which bills its own project. + public let quotaProject: String? } public extension GoogleTokenProviders { @@ -27,7 +30,8 @@ public extension GoogleTokenProviders { let key = try GoogleServiceAccountKey.parse(fieldValue: fieldValue, readFile: readFile) return GoogleCredentials( tokenProvider: serviceAccount(key, scopes: scopes, http: http), - projectHint: key.projectId + projectHint: key.projectId, + quotaProject: nil ) case .applicationDefault: let credentials = try GoogleApplicationDefaultCredentials.load( @@ -42,7 +46,8 @@ public extension GoogleTokenProviders { restrictsUserLogin: restrictsUserLogin, http: http ), - projectHint: credentials.projectHint + projectHint: credentials.projectHint, + quotaProject: credentials.quotaProject ) } } diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseClientTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseClientTests.swift new file mode 100644 index 0000000000..f320955559 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseClientTests.swift @@ -0,0 +1,222 @@ +import Foundation +@testable import TableProFirebaseCore +import Testing + +@Suite("Firestore client") +struct FirestoreClientTests { + @Test("runQuery reads documents and skips the empty read-time entry") + func runQuery() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(json: """ + [{"document": {"name": "projects/demo/databases/(default)/documents/users/alice", + "fields": {"age": {"integerValue": "30"}}, "updateTime": "2026-10-12T08:00:00Z"}, + "readTime": "2026-10-12T08:00:01Z"}, + {"readTime": "2026-10-12T08:00:01Z"}] + """) + let client = makeFirestoreClient(transport) + let documents = try await client.runQuery(parent: "", body: json(#"{"structuredQuery": {}}"#), operation: "read users") + #expect(documents == [FirestoreDocument(path: "users/alice", fields: ["age": .integer(30)], updateTime: "2026-10-12T08:00:00Z")]) + let request = try #require(transport.requests.first) + #expect(request.method == "POST") + #expect(request.url.absoluteString == "https://firestore.googleapis.com/v1/projects/demo/databases/(default)/documents:runQuery") + #expect(request.headers["Authorization"] == "Bearer owner") + } + + @Test("A denial names the operation and points at IAM and the rules") + func permissionDenied() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(status: 403, json: #"{"error": {"code": 403, "message": "Missing or insufficient permissions.", "status": "PERMISSION_DENIED"}}"#) + let client = makeFirestoreClient(transport) + do { + _ = try await client.runQuery(parent: "", body: .object([:]), operation: "read users") + Issue.record("Expected a denial") + } catch let error as FirebaseError { + guard case .permissionDenied(let operation, let detail) = error else { + Issue.record("Expected permissionDenied, got \(error)") + return + } + #expect(operation == "read users") + #expect(detail.contains("Missing or insufficient permissions.")) + #expect(error.localizedDescription.hasPrefix("Permission denied: read users.")) + } + } + + @Test("A 401 drops the cached token and retries once") + func retriesOnce() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(status: 401, json: #"{"error": {"message": "expired"}}"#) + transport.enqueue(json: #"{"collectionIds": ["users", "orders"]}"#) + let counter = CountingCredential() + let client = makeFirestoreClient(transport, credential: counter.credential) + #expect(try await client.listCollectionIds(parent: "") == ["users", "orders"]) + #expect(counter.invalidateCount == 1) + #expect(transport.requests.map { $0.headers["Authorization"] } == ["Bearer token-1", "Bearer token-2"]) + } + + @Test("A missing document is nil, not an error") + func missingDocument() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(status: 404, json: #"{"error": {"message": "not found"}}"#) + #expect(try await makeFirestoreClient(transport).getDocument("users/ghost") == nil) + } + + @Test("A patch sends the mask, the precondition and encoded path segments") + func patch() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(json: "{}") + try await makeFirestoreClient(transport).patchDocument( + "users/a b", fields: ["first-name": .string("A")], mask: ["first-name"], updateTime: "2026-10-12T08:00:00Z" + ) + let request = try #require(transport.requests.first) + #expect(request.method == "PATCH") + #expect(request.url.absoluteString == "https://firestore.googleapis.com/v1/projects/demo/databases/(default)/documents/users/a%20b?updateMask.fieldPaths=%60first-name%60¤tDocument.updateTime=2026-10-12T08:00:00Z") + #expect(String(data: request.body ?? Data(), encoding: .utf8) == #"{"fields":{"first-name":{"stringValue":"A"}}}"#) + } + + @Test("A colon in a document ID is encoded, so it never reads as a custom method") + func colonInDocumentID() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(status: 404, json: "{}") + transport.enqueue(json: "[]") + let client = makeFirestoreClient(transport) + _ = try await client.getDocument("users/uid:123") + _ = try await client.runQuery(parent: "users/uid:123", body: .object([:]), operation: "read") + #expect(transport.requests.map(\.url.absoluteString) == [ + "https://firestore.googleapis.com/v1/projects/demo/databases/(default)/documents/users/uid%3A123", + "https://firestore.googleapis.com/v1/projects/demo/databases/(default)/documents/users/uid%3A123:runQuery" + ]) + } + + @Test("Firestore's FAILED_PRECONDITION is a precondition, other 400s are not") + func failedPrecondition() { + let stale = Data(#"{"error": {"code": 400, "message": "stored version mismatch", "status": "FAILED_PRECONDITION"}}"#.utf8) + #expect(FirebaseError.from(status: 400, body: stale, operation: "write") == .precondition("stored version mismatch")) + let invalid = Data(#"{"error": {"code": 400, "message": "bad field", "status": "INVALID_ARGUMENT"}}"#.utf8) + #expect(FirebaseError.from(status: 400, body: invalid, operation: "write") == .api(status: 400, message: "bad field")) + } + + @Test("Only native-mode databases are listed") + func databases() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(json: """ + {"databases": [{"name": "projects/demo/databases/(default)", "type": "FIRESTORE_NATIVE"}, + {"name": "projects/demo/databases/legacy", "type": "DATASTORE_MODE"}, + {"name": "projects/demo/databases/eu", "type": "FIRESTORE_NATIVE"}]} + """) + #expect(try await makeFirestoreClient(transport).listDatabases() == ["(default)", "eu"]) + } + + @Test("The count comes from the aggregation result") + func count() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(json: #"[{"result": {"aggregateFields": {"total": {"integerValue": "1234"}}}, "readTime": "x"}]"#) + #expect(try await makeFirestoreClient(transport).count(parent: "", body: .object([:]), operation: "count") == 1_234) + } +} + +@Suite("Realtime Database client") +struct RealtimeDatabaseClientTests { + @Test("URLs end in .json, encode segments and keep the emulator namespace") + func urls() throws { + let transport = FakeFirebaseTransport() + let client = makeDatabaseClient(transport) + #expect(try client.url("/chats/a b", query: [URLQueryItem(name: "orderBy", value: "\"$key\"")]).absoluteString + == "https://demo-default-rtdb.europe-west1.firebasedatabase.app/chats/a%20b.json?orderBy=%22$key%22") + #expect(try client.url("").absoluteString == "https://demo-default-rtdb.europe-west1.firebasedatabase.app/.json") + let emulator = makeDatabaseClient(transport, url: "http://127.0.0.1:9000/?ns=demo-app") + #expect(try emulator.url("x", query: [URLQueryItem(name: "shallow", value: "true")]).absoluteString + == "http://127.0.0.1:9000/x.json?ns=demo-app&shallow=true") + #expect(emulator.instanceName == "demo-app") + #expect(client.instanceName == "demo-default-rtdb") + } + + @Test("Children come back in the requested order with the offset dropped") + func children() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(json: #"{"c": {"n": 3}, "a": {"n": 1}, "b": {"n": 2}}"#) + let plan = try RealtimeDatabaseQueryPlanner.plan(spec: FirebaseBrowseSpec(table: "x", limit: 2, offset: 1), typeOf: noTypes) + let children = try await makeDatabaseClient(transport).children("x", plan: plan) + #expect(children.map(\.key) == ["b", "c"]) + } + + @Test("An array node is read as children keyed by index, without its holes") + func arrays() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(json: #"[null, "one", "two"]"#) + let plan = try RealtimeDatabaseQueryPlanner.plan(spec: FirebaseBrowseSpec(table: "x", limit: 0, offset: 0), typeOf: noTypes) + let children = try await makeDatabaseClient(transport).children("x", plan: plan) + #expect(children.map(\.key) == ["1", "2"]) + } + + @Test("An instance asked for in the wrong region moves to the URL the server names, if it is Firebase's") + func regionRedirect() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(status: 404, json: #"{"correctUrl": "https://demo-default-rtdb.europe-west1.firebasedatabase.app", "error": "Database lives in a different region."}"#) + transport.enqueue(json: "null") + let client = makeDatabaseClient(transport, url: "https://demo-default-rtdb.firebaseio.com/") + try await client.locate() + #expect(client.currentURL.absoluteString == "https://demo-default-rtdb.europe-west1.firebasedatabase.app/") + #expect(transport.requests[1].url.host == "demo-default-rtdb.europe-west1.firebasedatabase.app") + + let hostile = FakeFirebaseTransport() + hostile.enqueue(status: 404, json: #"{"correctUrl": "https://evil.example.com", "error": "moved"}"#) + let stays = makeDatabaseClient(hostile, url: "https://demo-default-rtdb.firebaseio.com/") + await #expect(throws: FirebaseError.self) { try await stays.locate() } + #expect(stays.currentURL.host == "demo-default-rtdb.firebaseio.com") + #expect(hostile.requests.count == 1) + } + + @Test("The health check reads at most one child and asks for no body") + func ping() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(status: 204, json: "") + try await makeDatabaseClient(transport).ping() + #expect(transport.requests.first?.url.query == "orderBy=%22$key%22&limitToFirst=1&print=silent") + } + + @Test("The instance list names a quota project only when the API asks for one") + func quotaProjectRetry() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(status: 403, json: #"{"error": {"message": "The firebasedatabase.googleapis.com API requires a quota project, which is not set by default."}}"#) + transport.enqueue(json: #"{"instances": [{"databaseUrl": "https://b.europe-west1.firebasedatabase.app", "state": "ACTIVE"}, {"databaseUrl": "https://a.firebaseio.com", "state": "ACTIVE"}, {"databaseUrl": "https://c.firebaseio.com", "state": "DISABLED"}]}"#) + let lister = RealtimeDatabaseInstanceLister(transport: transport, credential: .emulator, timeout: { 30 }) + let urls = try await lister.instances(projectId: "demo") + #expect(urls.map(\.absoluteString) == ["https://a.firebaseio.com/", "https://b.europe-west1.firebasedatabase.app/"]) + #expect(transport.requests[0].headers["x-goog-user-project"] == nil) + #expect(transport.requests[1].headers["x-goog-user-project"] == "demo") + } + + @Test("A rules denial is a permission error, not an empty node") + func denied() async { + let transport = FakeFirebaseTransport() + transport.enqueue(status: 401, json: #"{"error": "Permission denied"}"#) + transport.enqueue(status: 403, json: #"{"error": "Permission denied"}"#) + do { + _ = try await makeDatabaseClient(transport).get("secrets") + Issue.record("Expected a denial") + } catch let error as FirebaseError { + #expect(error == .permissionDenied( + operation: "read /secrets", + detail: "The credential lacks the IAM role for this, or the security rules block it." + )) + } catch { + Issue.record("Unexpected \(error)") + } + } + + @Test("A guarded write sends if-match, and a moved ETag never shows the node's data as the error") + func guardedWrite() async throws { + let transport = FakeFirebaseTransport() + transport.enqueue(json: #"{"a": 1}"#, headers: ["ETag": "abc"]) + transport.enqueue(status: 412, json: #"{"message": "hi", "error": "private"}"#) + let client = makeDatabaseClient(transport) + let read = try await client.getWithETag("node") + #expect(read.etag == "abc") + await #expect(throws: FirebaseError.precondition("The node changed after it was read.")) { + try await client.put("node", value: .object(["a": .int(2)]), ifMatch: "abc") + } + #expect(transport.requests[0].headers["X-Firebase-ETag"] == "true") + #expect(transport.requests[1].headers["if-match"] == "abc") + #expect(transport.requests[1].url.query == nil) + } +} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseEmulatorTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseEmulatorTests.swift new file mode 100644 index 0000000000..e78d64124a --- /dev/null +++ b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseEmulatorTests.swift @@ -0,0 +1,123 @@ +import Foundation +@testable import TableProFirebaseCore +import Testing + +/// Runs against the Firebase emulators when their standard variables are set, for example +/// `FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 FIREBASE_DATABASE_EMULATOR_HOST=127.0.0.1:9000`. +enum FirebaseEmulatorEnvironment { + static let firestoreHost = ProcessInfo.processInfo.environment["FIRESTORE_EMULATOR_HOST"] + static let databaseHost = ProcessInfo.processInfo.environment["FIREBASE_DATABASE_EMULATOR_HOST"] + static let project = "demo-tablepro" + + static func transport() -> URLSessionFirebaseTransport { + URLSessionFirebaseTransport(resourceTimeout: 30) + } +} + +@Suite("Firestore emulator", .enabled(if: FirebaseEmulatorEnvironment.firestoreHost != nil), .serialized) +struct FirestoreEmulatorTests { + private func client() throws -> FirestoreClient { + let host = try #require(FirebaseEmulatorEnvironment.firestoreHost) + return FirestoreClient( + projectId: FirebaseEmulatorEnvironment.project, + database: "(default)", + baseURL: try #require(URL(string: "http://\(host)/v1/")), + transport: FirebaseEmulatorEnvironment.transport(), + credential: .emulator, + timeout: { 10 } + ) + } + + @Test("Create, query, count, patch with a stale time, and delete") + func lifecycle() async throws { + let client = try client() + let collection = "tp_\(UUID().uuidString.prefix(8))" + let created = try await client.createDocument(collectionPath: collection, documentId: "alice", fields: [ + "age": .integer(30), "when": .timestamp("2026-10-12T08:00:00Z"), "tags": .array([.string("vip")]) + ]) + _ = try await client.createDocument(collectionPath: collection, documentId: nil, fields: ["age": .integer(20)]) + #expect(created.path == "\(collection)/alice") + + let target = try FirestoreCollectionTarget(table: collection) + let spec = FirebaseBrowseSpec(table: collection, filters: [FirebaseFilterSpec(column: "age", op: ">=", value: "25")], limit: 10, offset: 0) + let types: (String) -> String? = { $0 == "age" ? "integer" : nil } + let query = try FirestoreQueryBuilder.structuredQuery(spec: spec, target: target, documentRoot: client.documentRoot, typeOf: types) + let documents = try await client.runQuery(parent: "", body: query, operation: "read") + #expect(documents.map(\.path) == ["\(collection)/alice"]) + let all = FirebaseBrowseSpec(table: collection, limit: 0, offset: 0) + let counted = try await client.count( + parent: "", body: try FirestoreQueryBuilder.countQuery(spec: all, target: target, documentRoot: client.documentRoot, typeOf: types), operation: "count" + ) + #expect(counted == 2) + + let stale = "2000-01-01T00:00:00Z" + await expectPrecondition { + try await client.patchDocument("\(collection)/alice", fields: ["age": .integer(31)], mask: ["age"], updateTime: stale) + } + try await client.patchDocument("\(collection)/alice", fields: ["age": .integer(31)], mask: ["age", "tags"]) + let patched = try #require(try await client.getDocument("\(collection)/alice")) + #expect(patched.fields["age"] == .integer(31)) + #expect(patched.fields["tags"] == nil) + #expect(patched.fields["when"] == .timestamp("2026-10-12T08:00:00Z")) + + #expect(try await client.listCollectionIds(parent: "").contains(collection)) + try await client.deleteDocument("\(collection)/alice") + #expect(try await client.getDocument("\(collection)/alice") == nil) + } +} + +@Suite("Realtime Database emulator", .enabled(if: FirebaseEmulatorEnvironment.databaseHost != nil), .serialized) +struct RealtimeDatabaseEmulatorTests { + private func client() throws -> RealtimeDatabaseClient { + let host = try #require(FirebaseEmulatorEnvironment.databaseHost) + return RealtimeDatabaseClient( + instanceURL: try #require(URL(string: "http://\(host)/?ns=\(FirebaseEmulatorEnvironment.project)")), + transport: FirebaseEmulatorEnvironment.transport(), + credential: .emulator, + timeout: { 10 } + ) + } + + @Test("Write, read in order, guard with an ETag, and delete") + func lifecycle() async throws { + let client = try client() + let node = "tp_\(UUID().uuidString.prefix(8))" + try await client.put(node, value: json(#"{"b": {"n": 2}, "a": {"n": 3}, "c": {"n": 1}}"#)) + let pushed = try await client.push(node, value: json(#"{"n": 0}"#)) + #expect(pushed != nil) + + let byField = FirebaseBrowseSpec(table: node, sorts: [FirebaseSortSpec(column: "n", ascending: true)], limit: 2, offset: 1) + let plan = try RealtimeDatabaseQueryPlanner.plan(spec: byField, typeOf: noTypes) + do { + _ = try await client.children(node, plan: plan) + Issue.record("Expected the missing index to be reported") + } catch let error as FirebaseError { + #expect(error.localizedDescription.contains(".indexOn")) + } + try await client.put(".settings/rules", value: json(#"{"rules": {".read": "auth != null", ".write": "auth != null", "\#(node)": {".indexOn": ["n"]}}}"#)) + #expect(try await client.children(node, plan: plan).map(\.key) == ["c", "b"]) + #expect(try await client.childKeys(node).count == 4) + + let read = try await client.getWithETag("\(node)/a") + let etag = try #require(read.etag) + try await client.patch("\(node)/a", values: ["n": .int(30)]) + await expectPrecondition { + try await client.put("\(node)/a", value: json(#"{"n": 99}"#), ifMatch: etag) + } + #expect(try await client.get("\(node)/a") == json(#"{"n": 30}"#)) + + try await client.delete(node) + #expect(try await client.get(node) == .null) + } +} + +/// A stale write has to fail as a precondition: any other error would mean the guard never ran. +private func expectPrecondition(_ body: () async throws -> Void, sourceLocation: SourceLocation = #_sourceLocation) async { + do { + try await body() + Issue.record("Expected a precondition failure", sourceLocation: sourceLocation) + } catch FirebaseError.precondition { + } catch { + Issue.record("Expected a precondition failure, got \(error)", sourceLocation: sourceLocation) + } +} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseJSONTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseJSONTests.swift new file mode 100644 index 0000000000..09ce4a2068 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseJSONTests.swift @@ -0,0 +1,43 @@ +import Foundation +@testable import TableProFirebaseCore +import Testing + +@Suite("FirebaseJSON") +struct FirebaseJSONTests { + @Test("Integers and doubles stay apart") + func numberKinds() throws { + #expect(try FirebaseJSON.parse("1") == .int(1)) + #expect(try FirebaseJSON.parse("1.0") == .double(1)) + #expect(try FirebaseJSON.parse("[1, 2.5]") == .array([.int(1), .double(2.5)])) + #expect(try FirebaseJSON.parse("9007199254740993") == .int(9_007_199_254_740_993)) + #expect(try FirebaseJSON.parse("true") == .bool(true)) + #expect(try FirebaseJSON.parse("0") == .int(0)) + } + + @Test("Text is deterministic, sorted and escaped") + func text() throws { + let value = try FirebaseJSON.parse(#"{"b": 1, "a": "x\"y\n", "c": [true, null, 1.5]}"#) + #expect(value.text() == #"{"a":"x\"y\n","b":1,"c":[true,null,1.5]}"#) + #expect(FirebaseJSON.double(3).text() == "3.0") + #expect(FirebaseJSON.object([:]).text() == "{}") + } + + @Test("Pretty text indents nested values") + func pretty() { + let value = FirebaseJSON.object(["a": .array([.int(1)])]) + #expect(value.text(pretty: true) == "{\n \"a\": [\n 1\n ]\n}") + } + + @Test("Invalid JSON throws a configuration-free malformed error") + func invalid() { + #expect(throws: FirebaseError.self) { try FirebaseJSON.parse("{nope") } + } + + @Test("Round trip through text keeps the value") + func roundTrip() throws { + let original = FirebaseJSON.object([ + "n": .int(-42), "d": .double(0.1), "s": .string("é \u{1F600}"), "z": .null, "l": .array([.bool(false)]) + ]) + #expect(try FirebaseJSON.parse(original.text()) == original) + } +} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseSettingsAndStatementTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseSettingsAndStatementTests.swift new file mode 100644 index 0000000000..09b115658c --- /dev/null +++ b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseSettingsAndStatementTests.swift @@ -0,0 +1,122 @@ +import Foundation +@testable import TableProFirebaseCore +import Testing + +@Suite("Firebase connection settings") +struct FirebaseSettingsTests { + @Test("Firestore takes the project from the credential when the field is empty") + func projectFromCredential() throws { + let settings = try FirebaseConnectionSettings.parse(fields: ["fbAuthMethod": "serviceAccount"], credentialProject: "my-app") + #expect(settings.variant == .firestore) + #expect(settings.projectId == "my-app") + #expect(settings.firestoreDatabase == "(default)") + #expect(settings.firestoreBaseURL?.absoluteString == "https://firestore.googleapis.com/v1/") + } + + @Test("The field wins over the credential, and a project is required for Firestore") + func projectRequired() throws { + let explicit = try FirebaseConnectionSettings.parse(fields: ["fbProjectId": "other-app"], credentialProject: "my-app") + #expect(explicit.projectId == "other-app") + #expect(throws: FirebaseError.self) { try FirebaseConnectionSettings.parse(fields: [:]) } + #expect(throws: FirebaseError.self) { try FirebaseConnectionSettings.parse(fields: ["fbProjectId": "Bad Project"]) } + } + + @Test("The Realtime Database variant needs a URL or a project") + func realtimeDatabase() throws { + let fields = ["driverVariant": "realtimeDatabase", "fbDatabaseURL": "https://chat-1.europe-west1.firebasedatabase.app/x"] + let settings = try FirebaseConnectionSettings.parse(fields: fields) + #expect(settings.variant == .realtimeDatabase) + #expect(settings.defaultDatabaseURL?.absoluteString == "https://chat-1.europe-west1.firebasedatabase.app/") + let fromProject = try FirebaseConnectionSettings.parse(fields: ["driverVariant": "realtimeDatabase", "fbProjectId": "my-app"]) + #expect(fromProject.defaultDatabaseURL?.absoluteString == "https://my-app-default-rtdb.firebaseio.com/") + #expect(throws: FirebaseError.self) { try FirebaseConnectionSettings.parse(fields: ["driverVariant": "realtimeDatabase"]) } + } + + @Test("A token is never sent to a host that is not Firebase's") + func untrustedURL() { + for url in ["http://chat.firebaseio.com", "https://evil.example.com", "https://firebaseio.com.evil.io/", "nonsense"] { + #expect(throws: FirebaseError.self) { + try FirebaseConnectionSettings.parse(fields: ["driverVariant": "realtimeDatabase", "fbDatabaseURL": url]) + } + } + } + + @Test("The emulator takes host:port and plain HTTP") + func emulator() throws { + let firestore = try FirebaseConnectionSettings.parse(fields: [ + "fbAuthMethod": "emulator", "fbProjectId": "demo-app", "fbEmulatorHost": "http://127.0.0.1:8080/" + ]) + #expect(firestore.firestoreBaseURL?.absoluteString == "http://127.0.0.1:8080/v1/") + let database = try FirebaseConnectionSettings.parse(fields: [ + "driverVariant": "realtimeDatabase", "fbAuthMethod": "emulator", "fbProjectId": "demo-app", "fbEmulatorHost": "localhost:9000" + ]) + #expect(database.defaultDatabaseURL?.absoluteString == "http://localhost:9000/?ns=demo-app-default-rtdb") + #expect(throws: FirebaseError.self) { + try FirebaseConnectionSettings.parse(fields: ["fbAuthMethod": "emulator", "fbProjectId": "demo-app", "fbEmulatorHost": "localhost"]) + } + } +} + +@Suite("Firebase statements") +struct FirebaseStatementTests { + @Test("A browse spec survives the statement text") + func browse() throws { + let spec = FirebaseBrowseSpec(table: "users", filters: [FirebaseFilterSpec(column: "a", op: "=", value: "1")], limit: 10, offset: 20) + #expect(try FirebaseStatement.parse(FirebaseStatement.encode(spec)) == .browse(spec)) + #expect(throws: FirebaseError.self) { try FirebaseStatement.parse("FIREBASE_BROWSE:not-base64!") } + } + + @Test("Request lines read verb, path, query and body") + func requests() throws { + let patch = try FirebaseRequest.parse(#"patch /users/alice?updateMask=age,name {"age": 31}"#) + #expect(patch.method == .patch) + #expect(patch.path == "users/alice") + #expect(patch.queryValue("updateMask") == "age,name") + #expect(patch.body == #"{"age": 31}"#) + let multiline = try FirebaseRequest.parse("PUT /config/maintenance\nfalse") + #expect(multiline.body == "false") + let query = try FirebaseRequest.parse(#"QUERY {"structuredQuery": {}}"#) + #expect(query.path.isEmpty && query.body == #"{"structuredQuery": {}}"#) + let scoped = try FirebaseRequest.parse(#"QUERY users/alice {"structuredQuery": {}}"#) + #expect(scoped.path == "users/alice") + #expect(try FirebaseRequest.parse("GET /").path.isEmpty) + } + + @Test("Malformed requests explain the syntax") + func malformed() { + #expect(throws: FirebaseError.self) { try FirebaseRequest.parse("SELECT * FROM users") } + #expect(throws: FirebaseError.self) { try FirebaseRequest.parse("PATCH users/alice") } + #expect(throws: FirebaseError.self) { try FirebaseRequest.parse("QUERY users") } + } + + @Test("The request text reads back as the same request") + func text() throws { + let request = FirebaseRequest(method: .patch, path: "users/alice", query: [URLQueryItem(name: "updateMask", value: "age")], body: #"{"age":31}"#) + #expect(request.text == #"PATCH users/alice?updateMask=age {"age":31}"#) + #expect(try FirebaseRequest.parse(request.text) == request) + } + + @Test("An ID with a space, & or = reads back whole, and the rest of the line stays readable") + func escapedText() throws { + let delete = FirebaseRequest(method: .delete, path: "chats/John Smith") + #expect(delete.text == "DELETE chats/John%20Smith") + #expect(try FirebaseRequest.parse(delete.text) == delete) + + let post = FirebaseRequest(method: .post, path: "users", + query: [URLQueryItem(name: "documentId", value: "a&b=c #1 50%")], body: #"{"n":1}"#) + #expect(post.text == #"POST users?documentId=a%26b%3Dc%20%231%2050%25 {"n":1}"#) + #expect(try FirebaseRequest.parse(post.text) == post) + + let unicode = FirebaseRequest(method: .get, path: "người/uid:1") + #expect(unicode.text == "GET người/uid:1") + #expect(try FirebaseRequest.parse(unicode.text) == unicode) + } + + @Test("An update mask quotes a field name that holds a comma or a backtick") + func mask() { + let text = FirestoreQueryBuilder.maskText(["age", "a,b", "x`y", "first name"]) + #expect(text == #"age,`a,b`,`x\`y`,`first name`"#) + #expect(FirestoreQueryBuilder.maskFields(text) == ["age", "a,b", "x`y", "first name"]) + #expect(FirestoreQueryBuilder.maskFields(" age , name ") == ["age", "name"]) + } +} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseTestSupport.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseTestSupport.swift new file mode 100644 index 0000000000..d8a961c087 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseTestSupport.swift @@ -0,0 +1,71 @@ +import Foundation +@testable import TableProFirebaseCore + +final class FakeFirebaseTransport: FirebaseTransport, @unchecked Sendable { + private let lock = NSLock() + private var queued: [FirebaseHTTPResponse] = [] + private var _requests: [FirebaseHTTPRequest] = [] + + var requests: [FirebaseHTTPRequest] { + lock.withLock { _requests } + } + + func enqueue(status: Int = 200, json: String, headers: [String: String] = [:]) { + lock.withLock { queued.append(FirebaseHTTPResponse(statusCode: status, headers: headers, body: Data(json.utf8))) } + } + + func send(_ request: FirebaseHTTPRequest) async throws -> FirebaseHTTPResponse { + try lock.withLock { + _requests.append(request) + guard !queued.isEmpty else { + throw FirebaseError.malformedResponse("No fake response for \(request.method) \(request.url)") + } + return queued.removeFirst() + } + } + + func cancelAll() {} +} + +final class CountingCredential: @unchecked Sendable { + private let lock = NSLock() + private var issued = 0 + private var invalidations = 0 + + var invalidateCount: Int { lock.withLock { invalidations } } + + var credential: FirebaseCredential { + FirebaseCredential( + token: { [self] in lock.withLock { issued += 1; return "token-\(issued)" } }, + invalidate: { [self] in lock.withLock { invalidations += 1 } } + ) + } +} + +let testDocumentRoot = "projects/demo/databases/(default)/documents" + +func makeFirestoreClient(_ transport: FakeFirebaseTransport, credential: FirebaseCredential = .emulator) -> FirestoreClient { + FirestoreClient( + projectId: "demo", + database: "(default)", + baseURL: URL(string: "https://firestore.googleapis.com/v1/") ?? URL(fileURLWithPath: "/"), + transport: transport, + credential: credential, + timeout: { 30 } + ) +} + +func makeDatabaseClient(_ transport: FakeFirebaseTransport, url: String = "https://demo-default-rtdb.europe-west1.firebasedatabase.app/") -> RealtimeDatabaseClient { + RealtimeDatabaseClient( + instanceURL: URL(string: url) ?? URL(fileURLWithPath: "/"), + transport: transport, + credential: .emulator, + timeout: { 30 } + ) +} + +func json(_ text: String) -> FirebaseJSON { + (try? FirebaseJSON.parse(text)) ?? .null +} + +func noTypes(_ column: String) -> String? { nil } diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreCellAndQueryTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreCellAndQueryTests.swift new file mode 100644 index 0000000000..dd4b2c27d5 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreCellAndQueryTests.swift @@ -0,0 +1,192 @@ +import Foundation +@testable import TableProFirebaseCore +import Testing + +@Suite("Firestore cells") +struct FirestoreCellTests { + @Test("Values display as text the grid can show") + func display() { + #expect(FirestoreCell.display(.null) == .null) + #expect(FirestoreCell.display(.boolean(true)) == .text("true")) + #expect(FirestoreCell.display(.double(2)) == .text("2.0")) + #expect(FirestoreCell.display(.geoPoint(latitude: 45.5, longitude: 9.0)) == .text("45.5, 9.0")) + #expect(FirestoreCell.display(.map(["a": .integer(1)])) == .text(#"{"a":1}"#)) + #expect(FirestoreCell.display(.bytes(Data([1]))) == .bytes(Data([1]))) + } + + @Test("An edit keeps the type the field had") + func typedEdits() throws { + #expect(try FirestoreCell.value(text: "42", typeName: "string") == .string("42")) + #expect(try FirestoreCell.value(text: " 42 ", typeName: "integer") == .integer(42)) + #expect(try FirestoreCell.value(text: "4.5", typeName: "double") == .double(4.5)) + #expect(try FirestoreCell.value(text: "FALSE", typeName: "boolean") == .boolean(false)) + #expect(try FirestoreCell.value(text: "2026-10-12T08:00:00Z", typeName: "timestamp") == .timestamp("2026-10-12T08:00:00Z")) + #expect(try FirestoreCell.value(text: "/users/bob", typeName: "reference") == .reference("users/bob")) + #expect(try FirestoreCell.value(text: "45.5, 9", typeName: "geopoint") == .geoPoint(latitude: 45.5, longitude: 9)) + #expect(try FirestoreCell.value(text: #"{"a": [1]}"#, typeName: "map") == .map(["a": .array([.integer(1)])])) + } + + @Test("An edit that does not fit the type is refused with an example") + func mismatch() { + #expect(throws: FirebaseError.self) { try FirestoreCell.value(text: "4.5", typeName: "integer") } + #expect(throws: FirebaseError.self) { try FirestoreCell.value(text: "soon", typeName: "timestamp") } + #expect(throws: FirebaseError.self) { try FirestoreCell.value(text: "users", typeName: "reference") } + #expect(throws: FirebaseError.self) { try FirestoreCell.value(text: "1", typeName: "geopoint") } + } + + @Test("A new field infers JSON literals and keeps other text as a string") + func inferred() { + #expect(FirestoreCell.inferred("12") == .integer(12)) + #expect(FirestoreCell.inferred("true") == .boolean(true)) + #expect(FirestoreCell.inferred("[1]") == .array([.integer(1)])) + #expect(FirestoreCell.inferred("hello") == .string("hello")) + #expect(FirestoreCell.inferred("\"quoted\"") == .string("\"quoted\"")) + } + + @Test("A column's type is what most of its cells hold") + func dominantType() { + #expect(FirestoreCell.dominantType([.string("a"), .null, .string("b"), .integer(1)]) == "string") + #expect(FirestoreCell.dominantType([nil, .null]) == "null") + #expect(FirestoreCell.dominantType([nil]) == "string") + } + + @Test("Rows put __name__ first and mark missing fields absent") + func rows() throws { + let target = try FirestoreCollectionTarget(table: "users") + let rows = FirestoreRowSet(documents: [ + FirestoreDocument(path: "users/alice", fields: ["age": .integer(30), "name": .string("Alice")]), + FirestoreDocument(path: "users/bob", fields: ["name": .string("Bob")]) + ], target: target) + #expect(rows.columns == ["__name__", "age", "name"]) + #expect(rows.columnTypes == ["string", "integer", "string"]) + #expect(rows.rows[0] == [.text("alice"), .text("30"), .text("Alice")]) + #expect(rows.absentCells == [1: [1]]) + #expect(rows.locators == ["users/alice", "users/bob"]) + #expect(rows.cellTypes["users/bob"] == ["name": "string"]) + } +} + +@Suite("Firestore query builder") +struct FirestoreQueryBuilderTests { + private func filterJSON(_ filter: FirebaseFilterSpec, types: [String: String] = [:], table: String = "users") throws -> String { + let spec = FirebaseBrowseSpec(table: table, filters: [filter], limit: 0, offset: 0) + let query = try FirestoreQueryBuilder.structuredQuery( + spec: spec, target: try FirestoreCollectionTarget(table: table), + documentRoot: testDocumentRoot, typeOf: { types[$0] } + ) + return query["structuredQuery"]?["where"]?.text() ?? "" + } + + @Test("A page carries from, order, offset and limit") + func page() throws { + let spec = FirebaseBrowseSpec( + table: "users/alice/orders", sorts: [FirebaseSortSpec(column: "total", ascending: false)], limit: 50, offset: 100 + ) + let target = try FirestoreCollectionTarget(table: spec.table) + #expect(target.parentPath == "users/alice") + let query = try FirestoreQueryBuilder.structuredQuery(spec: spec, target: target, documentRoot: testDocumentRoot, typeOf: noTypes) + let expected = #"{"structuredQuery":{"from":[{"allDescendants":false,"collectionId":"orders"}],"limit":50,"offset":100,"# + + #""orderBy":[{"direction":"DESCENDING","field":{"fieldPath":"total"}}]}}"# + #expect(query.text() == expected) + } + + @Test("Filter values are typed by the field's type") + func typedValues() throws { + #expect(try filterJSON(FirebaseFilterSpec(column: "age", op: ">=", value: "30"), types: ["age": "integer"]) + == #"{"fieldFilter":{"field":{"fieldPath":"age"},"op":"GREATER_THAN_OR_EQUAL","value":{"integerValue":"30"}}}"#) + #expect(try filterJSON(FirebaseFilterSpec(column: "zip", op: "=", value: "02134"), types: ["zip": "string"]) + == #"{"fieldFilter":{"field":{"fieldPath":"zip"},"op":"EQUAL","value":{"stringValue":"02134"}}}"#) + #expect(try filterJSON(FirebaseFilterSpec(column: "score", op: "!=", value: "7")) + == #"{"fieldFilter":{"field":{"fieldPath":"score"},"op":"NOT_EQUAL","value":{"integerValue":"7"}}}"#) + } + + @Test("__name__ compares document references") + func nameFilter() throws { + #expect(try filterJSON(FirebaseFilterSpec(column: "__name__", op: "=", value: "alice")) + == #"{"fieldFilter":{"field":{"fieldPath":"__name__"},"op":"EQUAL","value":{"referenceValue":"projects/demo/databases/(default)/documents/users/alice"}}}"#) + #expect(try filterJSON(FirebaseFilterSpec(column: "__name__", op: "=", value: "users/a/orders/1"), table: "*/orders") + .contains("documents/users/a/orders/1")) + } + + @Test("Range, list and null operators map to Firestore operators") + func operators() throws { + let between = try filterJSON(FirebaseFilterSpec(column: "age", op: "BETWEEN", value: "10,20", secondValue: "20"), types: ["age": "integer"]) + #expect(between.contains(#""op":"GREATER_THAN_OR_EQUAL","value":{"integerValue":"10"}"#)) + #expect(between.contains(#""op":"LESS_THAN_OR_EQUAL","value":{"integerValue":"20"}"#)) + let prefix = try filterJSON(FirebaseFilterSpec(column: "name", op: "STARTS WITH", value: "Al")) + #expect(prefix.contains("\"Al\u{F8FF}\"")) + let list = try filterJSON(FirebaseFilterSpec(column: "tier", op: "NOT IN", value: "gold, silver"), types: ["tier": "string"]) + #expect(list.contains(#""op":"NOT_IN""#) && list.contains(#"{"stringValue":"silver"}"#)) + #expect(try filterJSON(FirebaseFilterSpec(column: "x", op: "IS NULL", value: "")).contains("IS_NULL")) + #expect(try filterJSON(FirebaseFilterSpec(column: "tags", op: "CONTAINS", value: "vip"), types: ["tags": "array"]) + .contains("ARRAY_CONTAINS")) + } + + @Test("Operators Firestore lacks are refused, not approximated") + func unsupported() { + #expect(throws: FirebaseError.self) { try filterJSON(FirebaseFilterSpec(column: "name", op: "CONTAINS", value: "x"), types: ["name": "string"]) } + #expect(throws: FirebaseError.self) { try filterJSON(FirebaseFilterSpec(column: "name", op: "ENDS WITH", value: "x")) } + #expect(throws: FirebaseError.self) { try filterJSON(FirebaseFilterSpec(column: "name", op: "REGEX", value: "x")) } + #expect(throws: FirebaseError.self) { try filterJSON(FirebaseFilterSpec(column: "age", op: "=", value: "old"), types: ["age": "integer"]) } + } + + @Test("Several filters combine with the chosen logic, and a raw filter passes through") + func combine() throws { + let spec = FirebaseBrowseSpec(table: "users", filters: [ + FirebaseFilterSpec(column: "a", op: "=", value: "x"), + FirebaseFilterSpec(column: "__RAW__", op: "", value: #"{"unaryFilter": {"field": {"fieldPath": "b.c"}, "op": "IS_NAN"}}"#) + ], logicMode: "OR", limit: 10, offset: 0) + let query = try FirestoreQueryBuilder.structuredQuery( + spec: spec, target: try FirestoreCollectionTarget(table: "users"), documentRoot: testDocumentRoot, typeOf: noTypes + ) + let filter = query["structuredQuery"]?["where"]?.text() ?? "" + #expect(filter.hasPrefix(#"{"compositeFilter":{"filters":["#)) + #expect(filter.contains(#""op":"OR""#) && filter.contains("IS_NAN")) + let bad = FirebaseBrowseSpec(table: "users", filters: [FirebaseFilterSpec(column: "__RAW__", op: "", value: "age > 3")], limit: 1, offset: 0) + #expect(throws: FirebaseError.self) { + try FirestoreQueryBuilder.structuredQuery(spec: bad, target: try FirestoreCollectionTarget(table: "users"), documentRoot: testDocumentRoot, typeOf: noTypes) + } + } + + @Test("Counting drops the page but keeps the order, which leaves out documents without the field") + func count() throws { + let spec = FirebaseBrowseSpec(table: "*/orders", sorts: [FirebaseSortSpec(column: "a", ascending: true)], limit: 10, offset: 5) + let query = try FirestoreQueryBuilder.countQuery( + spec: spec, target: try FirestoreCollectionTarget(table: spec.table), documentRoot: testDocumentRoot, typeOf: noTypes + ) + let expected = #"{"structuredAggregationQuery":{"aggregations":[{"alias":"total","count":{}}],"# + + #""structuredQuery":{"from":[{"allDescendants":true,"collectionId":"orders"}],"# + + #""orderBy":[{"direction":"ASCENDING","field":{"fieldPath":"a"}}]}}}"# + #expect(query.text() == expected) + #expect(FirestoreQueryBuilder.sortedFields(spec) == ["a"]) + } + + @Test("BETWEEN splits the host's joined value by the upper bound, whatever commas the bounds hold") + func betweenBounds() { + let joined = FirebaseFilterSpec(column: "s", op: "BETWEEN", value: "a,b,c,d", secondValue: "c,d") + #expect(joined.betweenBounds?.lower == "a,b" && joined.betweenBounds?.upper == "c,d") + let bare = FirebaseFilterSpec(column: "s", op: "BETWEEN", value: "1,9") + #expect(bare.betweenBounds?.lower == "1" && bare.betweenBounds?.upper == "9") + #expect(FirebaseFilterSpec(column: "s", op: "BETWEEN", value: "1").betweenBounds == nil) + } + + @Test("Field paths are quoted when they need it") + func fieldPaths() { + #expect(FirestoreQueryBuilder.fieldPath("name") == "name") + #expect(FirestoreQueryBuilder.fieldPath("first-name") == "`first-name`") + #expect(FirestoreQueryBuilder.fieldPath("a`b") == "`a\\`b`") + #expect(FirestoreQueryBuilder.fieldPath("__name__") == "__name__") + } + + @Test("A table names a collection or a collection group, never a document") + func targets() throws { + #expect(try FirestoreCollectionTarget(table: "*/orders").isGroup) + #expect(throws: FirebaseError.self) { try FirestoreCollectionTarget(table: "users/alice") } + #expect(throws: FirebaseError.self) { try FirestoreCollectionTarget(table: "*/a/b") } + let group = try FirestoreCollectionTarget(table: "*/orders") + #expect(group.rowName(forDocumentPath: "users/a/orders/1") == "users/a/orders/1") + let collection = try FirestoreCollectionTarget(table: "users") + #expect(collection.rowName(forDocumentPath: "users/alice") == "alice") + #expect(collection.documentPath(forRowName: "alice") == "users/alice") + } +} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreValueTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreValueTests.swift new file mode 100644 index 0000000000..5cbfc242c8 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreValueTests.swift @@ -0,0 +1,122 @@ +import Foundation +@testable import TableProFirebaseCore +import Testing + +@Suite("Firestore values") +struct FirestoreValueTests { + @Test("Every REST value type decodes") + func decode() throws { + let rest = json(""" + {"mapValue": {"fields": { + "s": {"stringValue": "hi"}, + "i": {"integerValue": "9007199254740993"}, + "d": {"doubleValue": 1.5}, + "nan": {"doubleValue": "NaN"}, + "b": {"booleanValue": true}, + "n": {"nullValue": null}, + "t": {"timestampValue": "2026-10-12T08:00:00.123456Z"}, + "r": {"referenceValue": "projects/demo/databases/(default)/documents/users/alice"}, + "g": {"geoPointValue": {"latitude": 45.5, "longitude": 9.25}}, + "y": {"bytesValue": "aGk="}, + "a": {"arrayValue": {"values": [{"integerValue": "1"}, {"stringValue": "x"}]}}, + "e": {"arrayValue": {}} + }}} + """) + guard case .map(let fields) = try FirestoreValue(rest: rest, documentRoot: testDocumentRoot) else { + Issue.record("Expected a map") + return + } + #expect(fields["s"] == .string("hi")) + #expect(fields["i"] == .integer(9_007_199_254_740_993)) + #expect(fields["d"] == .double(1.5)) + if case .double(let value) = fields["nan"] { #expect(value.isNaN) } else { Issue.record("NaN lost") } + #expect(fields["b"] == .boolean(true)) + #expect(fields["n"] == .null) + #expect(fields["t"] == .timestamp("2026-10-12T08:00:00.123456Z")) + #expect(fields["r"] == .reference("users/alice")) + #expect(fields["g"] == .geoPoint(latitude: 45.5, longitude: 9.25)) + #expect(fields["y"] == .bytes(Data("hi".utf8))) + #expect(fields["a"] == .array([.integer(1), .string("x")])) + #expect(fields["e"] == .array([])) + } + + @Test("Encoding is the inverse of decoding") + func roundTrip() throws { + let values: [FirestoreValue] = [ + .null, .boolean(false), .integer(-7), .double(2.25), .timestamp("2026-01-01T00:00:00Z"), + .string(""), .bytes(Data([0, 255])), .reference("a/b/c/d"), .geoPoint(latitude: -10, longitude: 20), + .array([.integer(1), .map(["k": .string("v")])]), .map([:]) + ] + for value in values { + let decoded = try FirestoreValue(rest: value.rest(documentRoot: testDocumentRoot), documentRoot: testDocumentRoot) + #expect(decoded == value) + } + } + + @Test("Integers go on the wire as strings, references in full") + func wireShapes() { + #expect(FirestoreValue.integer(5).rest(documentRoot: testDocumentRoot).text() == #"{"integerValue":"5"}"#) + #expect( + FirestoreValue.reference("users/alice").rest(documentRoot: testDocumentRoot).text() + == #"{"referenceValue":"projects/demo/databases/(default)/documents/users/alice"}"# + ) + #expect(FirestoreValue.double(.infinity).rest(documentRoot: testDocumentRoot).text() == #"{"doubleValue":"Infinity"}"#) + } + + @Test("A value with no type or an unknown type is refused") + func malformed() { + #expect(throws: FirebaseError.self) { try FirestoreValue(rest: json("{}"), documentRoot: testDocumentRoot) } + #expect(throws: FirebaseError.self) { try FirestoreValue(rest: json(#"{"fooValue": 1}"#), documentRoot: testDocumentRoot) } + #expect(throws: FirebaseError.self) { try FirestoreValue(rest: json(#"{"integerValue": "x"}"#), documentRoot: testDocumentRoot) } + } + + @Test("Paths split into collection and document") + func paths() { + #expect(FirestorePath.isDocument("users/alice")) + #expect(!FirestorePath.isDocument("users")) + #expect(!FirestorePath.isDocument("users/alice/orders")) + #expect(FirestorePath.split(collectionPath: "users/alice/orders") == ("users/alice", "orders")) + #expect(FirestorePath.split(collectionPath: "users") == ("", "users")) + #expect(FirestorePath.absolute("/users/alice/", documentRoot: testDocumentRoot) == testDocumentRoot + "/users/alice") + #expect(FirestorePath.absolute("", documentRoot: testDocumentRoot) == testDocumentRoot) + } +} + +@Suite("Firestore document JSON") +struct FirestoreDocumentJSONTests { + @Test("Types JSON lacks are tagged and read back") + func tags() throws { + let fields: [String: FirestoreValue] = [ + "when": .timestamp("2026-10-12T08:00:00Z"), + "owner": .reference("users/alice"), + "where": .geoPoint(latitude: 45.4, longitude: 9.2), + "raw": .bytes(Data("hi".utf8)), + "whole": .double(3), + "half": .double(0.5), + "count": .integer(3), + "tags": .array([.string("a")]), + "nested": .map(["x": .null]) + ] + let text = FirestoreDocumentJSON.text(fields: fields) + #expect(text.contains(#""$timestamp": "2026-10-12T08:00:00Z""#)) + #expect(text.contains(#""$double": 3.0"#)) + #expect(text.contains(#""half": 0.5"#)) + #expect(try FirestoreDocumentJSON.fields(from: text) == fields) + } + + @Test("A real map shaped like a tag survives inside $map") + func mapEscape() throws { + let value = FirestoreValue.map(["$ref": .string("not a reference")]) + let encoded = FirestoreDocumentJSON.json(value) + #expect(encoded.text() == #"{"$map":{"$ref":"not a reference"}}"#) + #expect(try FirestoreDocumentJSON.value(encoded) == value) + } + + @Test("Invalid tags explain what they need") + func invalidTags() { + #expect(throws: FirebaseError.self) { try FirestoreDocumentJSON.value(json(#"{"$timestamp": "yesterday"}"#)) } + #expect(throws: FirebaseError.self) { try FirestoreDocumentJSON.value(json(#"{"$ref": "users"}"#)) } + #expect(throws: FirebaseError.self) { try FirestoreDocumentJSON.value(json(#"{"$geo": {"latitude": 99, "longitude": 0}}"#)) } + #expect(throws: FirebaseError.self) { try FirestoreDocumentJSON.fields(from: "[1, 2]") } + } +} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/RealtimeDatabaseTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/RealtimeDatabaseTests.swift new file mode 100644 index 0000000000..72c1d63238 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/RealtimeDatabaseTests.swift @@ -0,0 +1,145 @@ +import Foundation +@testable import TableProFirebaseCore +import Testing + +@Suite("Realtime Database query planning") +struct RealtimeDatabasePlannerTests { + private func items(_ spec: FirebaseBrowseSpec, types: [String: String] = [:]) throws -> [String] { + try RealtimeDatabaseQueryPlanner.plan(spec: spec, typeOf: { types[$0] }).queryItems.map { "\($0.name)=\($0.value ?? "")" } + } + + @Test("A plain page orders by key and reads offset plus limit") + func plainPage() throws { + let plan = try RealtimeDatabaseQueryPlanner.plan(spec: FirebaseBrowseSpec(table: "chats", limit: 50, offset: 100), typeOf: noTypes) + #expect(plan.dropCount == 100) + #expect(plan.queryItems.map { "\($0.name)=\($0.value ?? "")" } == [#"orderBy="$key""#, "limitToFirst=150"]) + } + + @Test("A descending sort reads from the end") + func descending() throws { + let spec = FirebaseBrowseSpec(table: "x", sorts: [FirebaseSortSpec(column: "score", ascending: false)], limit: 10, offset: 0) + #expect(try items(spec) == [#"orderBy="score""#, "limitToLast=10"]) + } + + @Test("One filter becomes bounds on its own field, typed by the column") + func filters() throws { + let equal = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "age", op: "=", value: "30")], limit: 5, offset: 0) + #expect(try items(equal, types: ["age": "number"]) == [#"orderBy="age""#, "equalTo=30", "limitToFirst=5"]) + let zip = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "zip", op: ">", value: "021")], limit: 5, offset: 0) + #expect(try items(zip, types: ["zip": "string"]) == [#"orderBy="zip""#, #"startAfter="021""#, "limitToFirst=5"]) + let key = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "$key", op: "BETWEEN", value: "a", secondValue: "m")], limit: 0, offset: 0) + #expect(try items(key) == [#"orderBy="$key""#, #"endAt="m""#, #"startAt="a""#]) + let prefix = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "name", op: "STARTS WITH", value: "Al")], limit: 0, offset: 0) + #expect(try items(prefix) == [#"orderBy="name""#, "endAt=\"Al\u{F8FF}\"", #"startAt="Al""#]) + } + + @Test("What the API cannot do is refused with the reason") + func refusals() { + let two = FirebaseBrowseSpec(table: "x", filters: [ + FirebaseFilterSpec(column: "a", op: "=", value: "1"), FirebaseFilterSpec(column: "b", op: "=", value: "2") + ], limit: 1, offset: 0) + #expect(throws: FirebaseError.self) { try RealtimeDatabaseQueryPlanner.plan(spec: two, typeOf: noTypes) } + let mixed = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "a", op: "=", value: "1")], + sorts: [FirebaseSortSpec(column: "b", ascending: true)], limit: 1, offset: 0) + #expect(throws: FirebaseError.self) { try RealtimeDatabaseQueryPlanner.plan(spec: mixed, typeOf: noTypes) } + let contains = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "a", op: "CONTAINS", value: "1")], limit: 1, offset: 0) + #expect(throws: FirebaseError.self) { try RealtimeDatabaseQueryPlanner.plan(spec: contains, typeOf: noTypes) } + } + + @Test("A raw filter is the query string as written, with the page limit added") + func raw() throws { + let spec = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "__RAW__", op: "", value: #"orderBy="age"&startAt=18"#)], limit: 20, offset: 0) + #expect(try items(spec) == [#"orderBy="age""#, "startAt=18", "limitToFirst=20"]) + } + + @Test("A raw filter's page is cut in the order it names, not by key") + func rawOrder() throws { + let spec = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "__RAW__", op: "", value: #"orderBy="age"&startAt=30"#)], limit: 2, offset: 1) + let plan = try RealtimeDatabaseQueryPlanner.plan(spec: spec, typeOf: noTypes) + #expect(plan.order == .child("age")) + let children: [(key: String, value: FirebaseJSON)] = [("a", json(#"{"age": 50}"#)), ("b", json(#"{"age": 30}"#)), ("c", json(#"{"age": 40}"#))] + let page = plan.window(RealtimeDatabaseOrdering.sort(children, order: plan.order, ascending: plan.ascending)) + #expect(page.map(\.key) == ["c", "a"]) + } + + @Test("A raw filter with no order gets a key order for its limit, and a shallow read gets neither") + func rawWithoutOrder() throws { + func raw(_ text: String) -> FirebaseBrowseSpec { + FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "__RAW__", op: "", value: text)], limit: 10, offset: 5) + } + #expect(try items(raw("print=pretty")) == ["print=pretty", #"orderBy="$key""#, "limitToFirst=15"]) + #expect(try items(raw("shallow=true")) == ["shallow=true"]) + let shallow = try RealtimeDatabaseQueryPlanner.plan(spec: raw("shallow=true"), typeOf: noTypes) + #expect(shallow.dropCount == 5 && shallow.keepCount == 10) + #expect(try items(raw(#"orderBy="n"&limitToLast=3"#)) == [#"orderBy="n""#, "limitToLast=3"]) + } + + @Test("BETWEEN reads the bounds the way the host sends them") + func betweenFromHost() throws { + let spec = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "age", op: "BETWEEN", value: "10,20", secondValue: "20")], limit: 0, offset: 0) + #expect(try items(spec, types: ["age": "number"]) == [#"orderBy="age""#, "endAt=20", "startAt=10"]) + } +} + +@Suite("Realtime Database ordering and rows") +struct RealtimeDatabaseRowTests { + @Test("Integer keys sort numerically before string keys") + func keyOrder() { + let keys = ["b", "10", "2", "a", "-1", "007"].sorted(by: RealtimeDatabaseOrdering.keyPrecedes) + #expect(keys == ["-1", "2", "10", "007", "a", "b"]) + } + + @Test("Values sort null, false, true, numbers, strings, objects") + func valueOrder() { + let children: [(key: String, value: FirebaseJSON)] = [ + ("o", .object(["v": .object([:])])), ("s", .object(["v": .string("x")])), ("n", .object(["v": .int(2)])), + ("d", .object(["v": .double(1.5)])), ("t", .object(["v": .bool(true)])), ("f", .object(["v": .bool(false)])), + ("z", .object([:])) + ] + let sorted = RealtimeDatabaseOrdering.sort(children, order: .child("v"), ascending: true).map(\.key) + #expect(sorted == ["z", "f", "t", "d", "n", "s", "o"]) + } + + @Test("Object children become columns, plain children a $value column") + func rows() { + let rows = RealtimeDatabaseRowSet(children: [ + ("op_1", json(#"{"name": "Ann", "calls": 3, "meta": {"a": 1}}"#)), + ("op_2", json(#"{"name": "Bo"}"#)), + ("flag", .bool(true)) + ], nodePath: "/operators") + #expect(rows.columns == ["$key", "calls", "meta", "name", "$value"]) + #expect(rows.rows[0] == ["op_1", "3", #"{"a":1}"#, "Ann", nil]) + #expect(rows.rows[2] == ["flag", nil, nil, nil, "true"]) + #expect(rows.absentCells[1] == [1, 2, 4]) + #expect(rows.locators == ["operators/op_1", "operators/op_2", "operators/flag"]) + #expect(rows.columnTypes == ["string", "number", "object", "string", "boolean"]) + #expect(rows.cellTypes["operators/op_1"]?["calls"] == "number") + } + + @Test("A node holding a plain value is one row located at the node") + func plainNode() { + let rows = RealtimeDatabaseRowSet(children: [("", .bool(false))], nodePath: "config/maintenance") + #expect(rows.rows == [["/config/maintenance", "false"]]) + #expect(rows.locators == ["config/maintenance"]) + } + + @Test("Cell edits keep the cell's type") + func cellEdits() throws { + #expect(try RealtimeDatabaseCell.value(text: "42", typeName: "string") == .string("42")) + #expect(try RealtimeDatabaseCell.value(text: "42", typeName: "number") == .int(42)) + #expect(try RealtimeDatabaseCell.value(text: "4.5", typeName: "number") == .double(4.5)) + #expect(try RealtimeDatabaseCell.value(text: "true", typeName: "boolean") == .bool(true)) + #expect(try RealtimeDatabaseCell.value(text: #"{"a":1}"#, typeName: "object") == .object(["a": .int(1)])) + #expect(try RealtimeDatabaseCell.value(text: "hello", typeName: nil) == .string("hello")) + #expect(try RealtimeDatabaseCell.value(text: "7", typeName: nil) == .int(7)) + #expect(throws: FirebaseError.self) { try RealtimeDatabaseCell.value(text: "x", typeName: "number") } + } + + @Test("Keys with forbidden characters are refused") + func keys() throws { + try RealtimeDatabasePath.validateKey("operator_1") + for bad in ["", "a.b", "a/b", "a$", "a#", "[x]"] { + #expect(throws: FirebaseError.self) { try RealtimeDatabasePath.validateKey(bad) } + } + } +} diff --git a/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleCredentialSourceTests.swift b/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleCredentialSourceTests.swift index 155880fe32..e1f41ea7d4 100644 --- a/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleCredentialSourceTests.swift +++ b/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleCredentialSourceTests.swift @@ -38,6 +38,7 @@ struct GoogleCredentialSourceTests { "scope": Self.loginScope ]) #expect(credentials.projectHint == "quota") + #expect(credentials.quotaProject == "quota") } @Test("An unrestricted user login keeps every scope it was granted") @@ -64,6 +65,7 @@ struct GoogleCredentialSourceTests { http: http ) #expect(pasted.projectHint == "key-project") + #expect(pasted.quotaProject == nil) let read = LockedBox<[String]>([]) let fromPath = try GoogleTokenProviders.credentials( diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index a9eefcb797..5a410e066b 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -61,7 +61,7 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 30) + #expect(DatabaseType.allKnownTypes.count == 32) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.tidb)) #expect(DatabaseType.allKnownTypes.contains(.databend)) @@ -78,6 +78,8 @@ struct DatabaseTypeTests { #expect(DatabaseType.allKnownTypes.contains(.kafka)) #expect(DatabaseType.allKnownTypes.contains(.cloudflareR2SQL)) #expect(DatabaseType.allKnownTypes.contains(.weaviate)) + #expect(DatabaseType.allKnownTypes.contains(.firestore)) + #expect(DatabaseType.allKnownTypes.contains(.firebaseRealtimeDatabase)) } /// The list has no duplicates, which a count alone would not catch: adding a type twice @@ -102,6 +104,15 @@ struct DatabaseTypeTests { #expect(DatabaseType.weaviate.pluginTypeId == "Weaviate") } + @Test("Both Firebase types resolve the Firebase icon and keep their own plugin type id") + func firebaseIdentity() { + #expect(DatabaseType.firestore.rawValue == "Firestore") + #expect(DatabaseType.firestore.iconName == "firebase-icon") + #expect(DatabaseType.firestore.pluginTypeId == "Firestore") + #expect(DatabaseType.firebaseRealtimeDatabase.iconName == "firebase-icon") + #expect(DatabaseType.firebaseRealtimeDatabase.pluginTypeId == "Firebase Realtime Database") + } + @Test("ScyllaDB resolves its own icon") func scyllaDBIdentity() { #expect(DatabaseType.scylladb.rawValue == "ScyllaDB") diff --git a/Plugins/FirebaseDriverPlugin/FirebaseCredentialFactory.swift b/Plugins/FirebaseDriverPlugin/FirebaseCredentialFactory.swift new file mode 100644 index 0000000000..79b79c28f8 --- /dev/null +++ b/Plugins/FirebaseDriverPlugin/FirebaseCredentialFactory.swift @@ -0,0 +1,59 @@ +import Foundation +import TableProFirebaseCore +import TableProGoogleCloud +import TableProPluginKit + +internal enum FirebaseCredentialFactory { + /// The Realtime Database REST API wants the two Firebase scopes beside `cloud-platform`. + static let scopes = [ + GoogleOAuthClient.cloudPlatformScope, + "https://www.googleapis.com/auth/firebase.database", + "https://www.googleapis.com/auth/userinfo.email" + ] + + struct Resolved { + let credential: FirebaseCredential + let projectHint: String? + } + + static func resolve(config: DriverConnectionConfig) throws -> Resolved { + let fields = config.additionalFields + let method = trimmed(fields[FirebaseFieldID.authMethod]).flatMap(FirebaseAuthMethod.init(rawValue:)) + ?? .serviceAccount + let source: GoogleCredentialSource + switch method { + case .emulator: + return Resolved(credential: .emulator, projectHint: nil) + case .serviceAccount: + guard let value = trimmed(fields[FirebaseFieldID.serviceAccountJson]) ?? trimmed(config.password) else { + throw FirebaseError.configuration(String(localized: "Choose a service account key file, or paste its JSON.")) + } + source = .serviceAccountKey(value) + case .applicationDefault: + source = .applicationDefault + } + let environment = ProcessInfo.processInfo.environment + let credentials = try GoogleTokenProviders.credentials( + from: source, + scopes: scopes, + readFile: { FileManager.default.contents(atPath: $0) }, + environment: environment, + http: URLSessionGoogleHTTPClient() + ) + let provider = credentials.tokenProvider + let environmentProject = source == .applicationDefault ? trimmed(environment["GOOGLE_CLOUD_PROJECT"]) : nil + return Resolved( + credential: FirebaseCredential( + token: { try await provider.accessToken() }, + invalidate: { await provider.invalidateCachedToken() }, + quotaProject: credentials.quotaProject + ), + projectHint: credentials.projectHint ?? environmentProject + ) + } + + private static func trimmed(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } + return value + } +} diff --git a/Plugins/FirebaseDriverPlugin/FirebasePlugin.swift b/Plugins/FirebaseDriverPlugin/FirebasePlugin.swift new file mode 100644 index 0000000000..42264bf53b --- /dev/null +++ b/Plugins/FirebaseDriverPlugin/FirebasePlugin.swift @@ -0,0 +1,159 @@ +import Foundation +import TableProFirebaseCore +import TableProPluginKit + +final class FirebasePlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Firebase Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Cloud Firestore and Firebase Realtime Database over their REST APIs" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Firestore" + static let realtimeDatabaseTypeId = "Firebase Realtime Database" + static let additionalDatabaseTypeIds: [String] = ["Firebase Realtime Database"] + static let databaseDisplayName = "Cloud Firestore" + static let iconName = "firebase-icon" + static let defaultPort = 0 + static let isDownloadable = true + + static func driverVariant(for databaseTypeId: String) -> String? { + databaseTypeId == realtimeDatabaseTypeId ? FirebaseVariant.realtimeDatabase.rawValue : nil + } + + static let connectionMode: ConnectionMode = .apiOnly + static let navigationModel: NavigationModel = .standard + static let pathFieldRole: PathFieldRole = .database + static let requiresAuthentication = true + static let brandColorHex = "#FFA000" + static let queryLanguageName = "REST" + static let editorLanguage: EditorLanguage = .javascript + static let supportsForeignKeys = false + static let supportsSchemaEditing = false + static let supportsDatabaseSwitching = true + static let supportsImport = false + static let supportsExport = true + static let supportsSSH = false + static let supportsSSL = false + static let supportsReadOnlyMode = true + static let supportsForeignKeyDisable = false + static let supportsAddColumn = false + static let supportsModifyColumn = false + static let supportsDropColumn = false + static let supportsAddIndex = false + static let supportsDropIndex = false + static let supportsModifyPrimaryKey = false + static let supportsDocumentEditing = true + static let supportsFieldRemoval = true + static let databaseGroupingStrategy: GroupingStrategy = .byDatabase + static let defaultGroupName = FirebaseConnectionSettings.defaultFirestoreDatabase + static let tableEntityName = "Collections" + static let containerEntityName = "Database" + static let defaultSchemaName = "" + static let immutableColumns: [String] = [FirebaseColumns.documentName, FirebaseColumns.key] + static let defaultPrimaryKeyColumn: String? = FirebaseColumns.documentName + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable] + static let sqlDialect: SQLDialectDescriptor? = nil + + static let columnTypesByCategory: [String: [String]] = firebaseColumnTypes + static let additionalConnectionFields: [ConnectionField] = firebaseConnectionFields(variant: .firestore) + static var statementCompletions: [CompletionEntry] { firebaseCompletions } + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + FirebasePluginDriver(config: config) + } +} + +enum FirebaseColumns { + static let documentName = FirestoreQueryBuilder.nameField + static let key = RealtimeDatabaseQueryPlanner.keyColumn + static let value = RealtimeDatabaseQueryPlanner.valueColumn +} + +func firebaseConnectionFields(variant: FirebaseVariant) -> [ConnectionField] { + var fields: [ConnectionField] = [ + ConnectionField( + id: FirebaseFieldID.authMethod, + label: String(localized: "Auth Method"), + defaultValue: FirebaseAuthMethod.serviceAccount.rawValue, + fieldType: .dropdown(options: [ + .init(value: FirebaseAuthMethod.serviceAccount.rawValue, label: "Service Account Key"), + .init(value: FirebaseAuthMethod.applicationDefault.rawValue, label: "Application Default Credentials"), + .init(value: FirebaseAuthMethod.emulator.rawValue, label: "Emulator (no credentials)") + ]), + section: .authentication + ), + ConnectionField( + id: FirebaseFieldID.serviceAccountJson, + label: String(localized: "Service Account Key"), + placeholder: "File path or paste JSON", + required: true, + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: FirebaseFieldID.authMethod, values: [FirebaseAuthMethod.serviceAccount.rawValue]) + ).withHidesUsername(true), + ConnectionField( + id: FirebaseFieldID.projectId, + label: String(localized: "Project ID"), + placeholder: "Taken from the key when empty", + section: .authentication + ) + ] + switch variant { + case .firestore: + fields.append(ConnectionField( + id: FirebaseFieldID.firestoreDatabase, + label: String(localized: "Database"), + placeholder: FirebaseConnectionSettings.defaultFirestoreDatabase, + section: .authentication + )) + case .realtimeDatabase: + fields.append(ConnectionField( + id: FirebaseFieldID.databaseURL, + label: String(localized: "Database URL"), + placeholder: "https://..firebasedatabase.app", + section: .authentication + )) + } + fields.append(ConnectionField( + id: FirebaseFieldID.emulatorHost, + label: String(localized: "Emulator Host"), + placeholder: variant == .firestore ? "127.0.0.1:8080" : "127.0.0.1:9000", + required: true, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: FirebaseFieldID.authMethod, values: [FirebaseAuthMethod.emulator.rawValue]) + )) + return fields +} + +let firebaseCompletions: [CompletionEntry] = [ + CompletionEntry(label: "GET collection", insertText: "GET users?pageSize=20"), + CompletionEntry(label: "GET document", insertText: "GET users/alice"), + CompletionEntry( + label: "QUERY", + insertText: """ + QUERY { + "structuredQuery": { + "from": [{"collectionId": "users"}], + "where": {"fieldFilter": {"field": {"fieldPath": "age"}, "op": "GREATER_THAN", "value": {"integerValue": "30"}}}, + "limit": 20 + } + } + """ + ), + CompletionEntry(label: "PATCH", insertText: "PATCH users/alice?updateMask=age {\"age\": 31}"), + CompletionEntry(label: "POST", insertText: "POST users {\"name\": \"Bob\"}"), + CompletionEntry(label: "DELETE", insertText: "DELETE users/alice"), + CompletionEntry(label: "GET node", insertText: "GET /chats?orderBy=\"$key\"&limitToFirst=20"), + CompletionEntry(label: "GET shallow", insertText: "GET /?shallow=true"), + CompletionEntry(label: "PUT node", insertText: "PUT /config/maintenance false") +] + +let firebaseColumnTypes: [String: [String]] = [ + "Text": ["string", "reference"], + "Numeric": ["integer", "double", "number"], + "Boolean": ["boolean"], + "Date": ["timestamp"], + "Structured": ["map", "array", "object", "geopoint"], + "Binary": ["bytes"] +] diff --git a/Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Editing.swift b/Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Editing.swift new file mode 100644 index 0000000000..5c84b7b8f9 --- /dev/null +++ b/Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Editing.swift @@ -0,0 +1,447 @@ +import Foundation +import TableProFirebaseCore +import TableProPluginKit + +extension FirebasePluginDriver { + // MARK: - Grid edits + + func generateRowWrites( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) throws -> [PluginRowWrite]? { + try generateRowWrites( + table: table, schema: schema, columns: columns, primaryKeyColumns: primaryKeyColumns, + changes: changes, insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices, + context: PluginRowWriteContext() + ) + } + + /// Each change becomes the request line the console takes, so the history shows what was sent. + func generateRowWrites( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set, + context: PluginRowWriteContext + ) throws -> [PluginRowWrite]? { + let types = writeColumnTypes(table, host: context.columnTypeNames) + return try changes.compactMap { change in + do { + let row = insertedRowData[change.rowIndex] ?? [] + return try rowRequest(change, row: row, table: table, columns: columns, types: types, restoring: false) + .map { PluginRowWrite(statement: $0.text, rowIndices: [change.rowIndex]) } + } catch let refusal as PluginRowWriteRefusal { + throw refusal + } catch { + throw PluginRowWriteRefusal(rowIndex: change.rowIndex, reason: error.localizedDescription) + } + } + } + + func generateIdentityPreservingInsert( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + rows: [[PluginCellValue]] + ) -> [(statement: String, parameters: [PluginCellValue])]? { + generateIdentityPreservingInsert( + table: table, schema: schema, columns: columns, primaryKeyColumns: primaryKeyColumns, + rows: rows, absentCells: [:] + ) + } + + /// A restored row keeps its ID, leaves out the fields it never had, and types each value by + /// the field it was read from. + func generateIdentityPreservingInsert( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + rows: [[PluginCellValue]], + absentCells: [Int: Set] + ) -> [(statement: String, parameters: [PluginCellValue])]? { + let types = writeColumnTypes(table, host: [:]) + var statements: [(statement: String, parameters: [PluginCellValue])] = [] + for (index, row) in rows.enumerated() { + var change = PluginRowChange(rowIndex: index, type: .insert, cellChanges: [], originalRow: nil) + change.absentColumns = absentCells[index] ?? [] + guard let request = try? rowRequest(change, row: row, table: table, columns: columns, types: types, restoring: true) else { + return nil + } + statements.append((request.text, [])) + } + return statements + } + + private func rowRequest( + _ change: PluginRowChange, + row: [PluginCellValue], + table: String, + columns: [String], + types: [String: String], + restoring: Bool + ) throws -> FirebaseRequest? { + switch variant { + case .firestore: + return try firestoreRequest(change, row: row, table: table, columns: columns, types: types, restoring: restoring) + case .realtimeDatabase: + return try databaseRequest(change, row: row, table: table, columns: columns, types: types, restoring: restoring) + } + } + + private func firestoreRequest( + _ change: PluginRowChange, + row: [PluginCellValue], + table: String, + columns: [String], + types: [String: String], + restoring: Bool + ) throws -> FirebaseRequest? { + let target = try FirestoreCollectionTarget(table: table) + let nameColumn = FirestoreQueryBuilder.nameField + let absent = change.absentColumns ?? [] + switch change.type { + case .insert: + let name = columns.firstIndex(of: nameColumn).flatMap { $0 < row.count ? row[$0].asText : nil }.flatMap { $0.isEmpty ? nil : $0 } + let path = name.map(target.documentPath(forRowName:)) + var fields: [String: FirestoreValue] = [:] + for (index, column) in columns.enumerated() where index < row.count && column != nameColumn && !absent.contains(index) { + fields[column] = try firestoreValue(row[index], type: writeType(column, locator: path, columnTypes: types)) + } + let body = FirebaseJSON.object(fields.mapValues(FirestoreDocumentJSON.json)).text() + guard let path else { + guard !target.isGroup, !restoring else { + throw FirebaseError.configuration(String( + localized: "A new row in a collection group needs __name__ set to the full document path, such as users/alice/orders/1." + )) + } + return FirebaseRequest(method: .post, path: target.collectionPath, body: body) + } + if !target.isGroup, let name, name.contains("/") { + throw FirebaseError.configuration(String(localized: "__name__ is the document's ID in this collection, such as alice.")) + } + guard FirestorePath.isDocument(path) else { + throw FirebaseError.configuration(String( + localized: "A new row in a collection group needs __name__ set to the full document path, such as users/alice/orders/1." + )) + } + let split = FirestorePath.split(collectionPath: path) + return FirebaseRequest(method: .post, path: split.parent, + query: [URLQueryItem(name: "documentId", value: split.collectionId)], body: body) + case .update: + let path = try documentPath(change, columns: columns, target: target) + var fields: [String: FirestoreValue] = [:] + var mask: [String] = [] + for cell in change.cellChanges where cell.columnName != nameColumn { + mask.append(cell.columnName) + guard !absent.contains(cell.columnIndex) else { continue } + fields[cell.columnName] = try firestoreValue( + cell.newValue, type: writeType(cell.columnName, locator: path, columnTypes: types) + ) + } + for index in absent.sorted() where index < columns.count && columns[index] != nameColumn && !mask.contains(columns[index]) { + mask.append(columns[index]) + } + guard !mask.isEmpty else { return nil } + return FirebaseRequest(method: .patch, path: path, + query: [URLQueryItem(name: "updateMask", value: FirestoreQueryBuilder.maskText(mask))], + body: FirebaseJSON.object(fields.mapValues(FirestoreDocumentJSON.json)).text()) + case .delete: + return FirebaseRequest(method: .delete, path: try documentPath(change, columns: columns, target: target)) + } + } + + private func firestoreValue(_ cell: PluginCellValue, type: String?) throws -> FirestoreValue { + switch cell { + case .null: + return .null + case .bytes(let data): + return .bytes(data) + case .text(let text): + return try FirestoreCell.value(text: text, typeName: type) + } + } + + private func documentPath(_ change: PluginRowChange, columns: [String], target: FirestoreCollectionTarget) throws -> String { + guard let index = columns.firstIndex(of: FirestoreQueryBuilder.nameField), + let row = change.originalRow, index < row.count, let name = row[index].asText, !name.isEmpty else { + throw FirebaseError.configuration(String(localized: "The row has no __name__, so the document it came from is unknown. Reload the collection.")) + } + return target.documentPath(forRowName: name) + } + + private func databaseRequest( + _ change: PluginRowChange, + row: [PluginCellValue], + table: String, + columns: [String], + types: [String: String], + restoring: Bool + ) throws -> FirebaseRequest? { + let node = Self.nodePath(table) + let keyColumn = FirebaseColumns.key + let valueColumn = FirebaseColumns.value + let absent = change.absentColumns ?? [] + switch change.type { + case .insert: + let key = columns.firstIndex(of: keyColumn).flatMap { $0 < row.count ? row[$0].asText : nil }.flatMap { $0.isEmpty ? nil : $0 } + let locator = try key.map { try databaseInsertLocator($0, node: node, restoring: restoring) } + var members: [String: FirebaseJSON] = [:] + var plain: FirebaseJSON? + for (index, column) in columns.enumerated() where index < row.count && column != keyColumn && !absent.contains(index) { + guard let text = row[index].asText else { continue } + let value = try RealtimeDatabaseCell.value(text: text, typeName: writeType(column, locator: locator, columnTypes: types)) + if column == valueColumn { + plain = value + } else { + members[column] = value + } + } + let value = members.isEmpty ? (plain ?? .null) : .object(members) + guard value != .null else { + throw FirebaseError.configuration(String(localized: "A new child needs at least one value. The Realtime Database does not store empty nodes.")) + } + guard let locator else { + guard !restoring else { + throw FirebaseError.configuration(String(localized: "The row has no $key, so the node it came from is unknown. Reload the table.")) + } + return FirebaseRequest(method: .post, path: node, body: value.text()) + } + return FirebaseRequest(method: .put, path: locator, body: value.text()) + case .update: + let locator = try databaseLocator(change, columns: columns, node: node) + var members: [String: FirebaseJSON] = [:] + for cell in change.cellChanges where cell.columnName != keyColumn { + let type = writeType(cell.columnName, locator: locator, columnTypes: types) + let value = absent.contains(cell.columnIndex) + ? .null + : try cell.newValue.asText.map { try RealtimeDatabaseCell.value(text: $0, typeName: type) } ?? .null + if cell.columnName == valueColumn { + return FirebaseRequest(method: .put, path: locator, body: value.text()) + } + members[cell.columnName] = value + } + for index in absent.sorted() where index < columns.count && ![keyColumn, valueColumn].contains(columns[index]) { + members[columns[index]] = members[columns[index]] ?? .null + } + guard !members.isEmpty else { return nil } + for key in members.keys { try RealtimeDatabasePath.validateKey(key) } + return FirebaseRequest(method: .patch, path: locator, body: FirebaseJSON.object(members).text()) + case .delete: + return FirebaseRequest(method: .delete, path: try databaseLocator(change, columns: columns, node: node)) + } + } + + /// Only a restore may name the node itself, since writing there replaces everything under it. + private func databaseInsertLocator(_ key: String, node: String, restoring: Bool) throws -> String { + if restoring, key == RealtimeDatabasePath.nodeKey(node) { + return node + } + try RealtimeDatabasePath.validateKey(key) + return RealtimeDatabasePath.join(node, key) + } + + private func databaseLocator(_ change: PluginRowChange, columns: [String], node: String) throws -> String { + guard let index = columns.firstIndex(of: FirebaseColumns.key), + let row = change.originalRow, index < row.count, let key = row[index].asText, !key.isEmpty else { + throw FirebaseError.configuration(String(localized: "The row has no $key, so the node it came from is unknown. Reload the table.")) + } + if key == RealtimeDatabasePath.nodeKey(node) { + return node + } + try RealtimeDatabasePath.validateKey(key) + return RealtimeDatabasePath.join(node, key) + } + + // MARK: - Whole documents + + static let unknownWriteMessage = String(localized: "This version of the Firebase driver cannot make that kind of document write. Update the plugin.") + + func documentWriteStatement(_ write: PluginDocumentWrite) throws -> String? { + try documentRequest(write).text + } + + func executeDocumentWrite(_ write: PluginDocumentWrite) async throws { + switch variant { + case .firestore: + try await executeFirestoreDocumentWrite(write) + case .realtimeDatabase: + try await executeDatabaseDocumentWrite(write) + } + } + + func fetchDocument(table: String, schema: String?, locator: String) async throws -> String? { + switch variant { + case .firestore: + guard let document = try await requireFirestore().getDocument(locator) else { return nil } + return Self.documentText(path: document.path, fields: document.fields) + case .realtimeDatabase: + let value = try await requireRealtimeDatabase().get(locator) + return value == .null ? nil : Self.nodeText(path: locator, value: value) + } + } + + /// Carries the path under `__name__`, so a rename can be refused instead of written elsewhere. + static func documentText(path: String, fields: [String: FirestoreValue]) -> String { + var members = fields.mapValues(FirestoreDocumentJSON.json) + members[FirestoreQueryBuilder.nameField] = .string(path) + return FirebaseJSON.object(members).text(pretty: true) + } + + static func nodeText(path: String, value: FirebaseJSON) -> String { + FirebaseJSON.object([FirebaseColumns.key: .string(path), FirebaseColumns.value: value]).text(pretty: true) + } + + private func documentRequest(_ write: PluginDocumentWrite) throws -> FirebaseRequest { + switch variant { + case .firestore: + switch write.operation { + case .insert(let text): + let parsed = try Self.splitDocument(text) + let target = try FirestoreCollectionTarget(table: write.table) + if let path = parsed.path, path.contains("/") { + let split = FirestorePath.split(collectionPath: path) + return FirebaseRequest(method: .post, path: split.parent, + query: [URLQueryItem(name: "documentId", value: split.collectionId)], body: parsed.body) + } + guard !target.isGroup else { + throw FirebaseError.configuration(String(localized: "A document added to a collection group needs __name__ set to its full path.")) + } + return FirebaseRequest(method: .post, path: target.collectionPath, + query: parsed.path.map { [URLQueryItem(name: "documentId", value: $0)] } ?? [], body: parsed.body) + case .replace(let original, let edited): + let before = try Self.splitDocument(original) + let after = try Self.splitDocument(edited) + guard let path = before.path else { + throw FirebaseError.configuration(String(localized: "The document lost its __name__. Reopen it.")) + } + if let renamed = after.path, renamed != path { + throw FirebaseError.unsupported(String(localized: "A document cannot be renamed. Add a new document and delete this one.")) + } + return FirebaseRequest(method: .patch, path: path, body: after.body) + @unknown default: + throw FirebaseError.unsupported(Self.unknownWriteMessage) + } + case .realtimeDatabase: + switch write.operation { + case .insert(let text): + let parsed = try Self.splitNode(text) + let node = Self.nodePath(write.table) + if let key = parsed.key { + try RealtimeDatabasePath.validateKey(key) + return FirebaseRequest(method: .put, path: RealtimeDatabasePath.join(node, key), body: parsed.value.text()) + } + return FirebaseRequest(method: .post, path: node, body: parsed.value.text()) + case .replace(let original, let edited): + let before = try Self.splitNode(original) + let after = try Self.splitNode(edited) + guard let path = before.key else { + throw FirebaseError.configuration(String(localized: "The node lost its $key. Reopen it.")) + } + if let renamed = after.key, renamed != path { + throw FirebaseError.unsupported(String(localized: "A node cannot be moved. Add it under the new key and delete this one.")) + } + return FirebaseRequest(method: .put, path: path, body: after.value.text()) + @unknown default: + throw FirebaseError.unsupported(Self.unknownWriteMessage) + } + } + } + + private func executeFirestoreDocumentWrite(_ write: PluginDocumentWrite) async throws { + let client = try requireFirestore() + let request = try documentRequest(write) + let fields = try FirestoreDocumentJSON.fields(from: request.body ?? "{}") + switch write.operation { + case .insert: + _ = try await client.createDocument(collectionPath: request.path, documentId: request.queryValue("documentId"), fields: fields) + case .replace(let original, _): + let expected = try FirestoreDocumentJSON.fields(from: try Self.splitDocument(original).body) + guard let current = try await client.getDocument(request.path) else { + throw FirebaseError.precondition(String(localized: "The document was deleted after it was opened.")) + } + guard Self.sameFields(current.fields, expected) else { + throw FirebaseError.precondition(String(localized: "The document changed after it was opened. Reopen it and edit again.")) + } + do { + try await client.patchDocument(request.path, fields: fields, mask: nil, updateTime: current.updateTime) + } catch FirebaseError.precondition { + throw FirebaseError.precondition(String(localized: "The document changed after it was opened. Reopen it and edit again.")) + } + @unknown default: + throw FirebaseError.unsupported(Self.unknownWriteMessage) + } + } + + private func executeDatabaseDocumentWrite(_ write: PluginDocumentWrite) async throws { + let client = try requireRealtimeDatabase() + let request = try documentRequest(write) + let value = try FirebaseJSON.parse(request.body ?? "null") + switch write.operation { + case .insert: + if request.method == .post { + _ = try await client.push(request.path, value: value) + } else { + try await client.put(request.path, value: value) + } + case .replace(let original, _): + let expected = try Self.splitNode(original).value + let current = try await client.getWithETag(request.path) + guard current.value == expected else { + throw FirebaseError.precondition(String(localized: "The node changed after it was opened. Reopen it and edit again.")) + } + do { + try await client.put(request.path, value: value, ifMatch: current.etag) + } catch FirebaseError.precondition { + throw FirebaseError.precondition(String(localized: "The node changed after it was opened. Reopen it and edit again.")) + } + @unknown default: + throw FirebaseError.unsupported(Self.unknownWriteMessage) + } + } + + /// NaN never equals itself, so a document holding one would always look changed. + static func sameFields(_ lhs: [String: FirestoreValue], _ rhs: [String: FirestoreValue]) -> Bool { + FirebaseJSON.object(lhs.mapValues(FirestoreDocumentJSON.json)).text() + == FirebaseJSON.object(rhs.mapValues(FirestoreDocumentJSON.json)).text() + } + + static func splitDocument(_ text: String) throws -> (path: String?, body: String) { + guard case .object(var members) = try FirebaseJSON.parse(text) else { + throw FirebaseError.configuration(String(localized: "A Firestore document is a JSON object of fields.")) + } + let name = members.removeValue(forKey: FirestoreQueryBuilder.nameField) + if let name, name.stringValue == nil { + throw FirebaseError.configuration(String(localized: "__name__ is the document's ID or path, as text.")) + } + let path = name?.stringValue?.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).nonEmptyPath + return (path, FirebaseJSON.object(members).text()) + } + + /// Bare JSON that is not `{"$key": ..., "$value": ...}` is a new child under a generated key. + static func splitNode(_ text: String) throws -> (key: String?, value: FirebaseJSON) { + let json = try FirebaseJSON.parse(text) + guard case .object(let members) = json, let value = members[FirebaseColumns.value], + members.keys.allSatisfy({ $0 == FirebaseColumns.key || $0 == FirebaseColumns.value }) else { + return (nil, json) + } + let key = members[FirebaseColumns.key]?.stringValue.map(RealtimeDatabasePath.normalized)?.nonEmptyPath + return (key, value) + } +} + +private extension String { + var nonEmptyPath: String? { isEmpty ? nil : self } +} diff --git a/Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Execution.swift b/Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Execution.swift new file mode 100644 index 0000000000..f60e627413 --- /dev/null +++ b/Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Execution.swift @@ -0,0 +1,338 @@ +import Foundation +import TableProFirebaseCore +import TableProPluginKit + +extension FirebasePluginDriver { + static let consolePageSize = 100 + static let exportPageSize = 500 + /// Export fixes its columns from the first page, so a field only later documents have goes here + /// as JSON rather than being dropped. + static let extraColumn = "__extra__" + + func execute(query: String) async throws -> PluginQueryResult { + let started = Date() + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased() == "select 1" { + try await ping() + return PluginQueryResult(columns: ["ok"], columnTypeNames: ["integer"], rows: [[.text("1")]], + rowsAffected: 0, executionTime: elapsed(since: started)) + } + switch try FirebaseStatement.parse(trimmed) { + case .browse(let spec): + return try await browse(spec, started: started) + case .request(let request): + switch variant { + case .firestore: + return try await executeFirestore(request, started: started) + case .realtimeDatabase: + return try await executeRealtimeDatabase(request, started: started) + } + } + } + + // MARK: - Browse + + func browse(_ spec: FirebaseBrowseSpec, started: Date = Date()) async throws -> PluginQueryResult { + switch variant { + case .firestore: + return try await browseFirestore(spec, started: started) + case .realtimeDatabase: + return try await browseRealtimeDatabase(spec, started: started) + } + } + + private func browseFirestore(_ spec: FirebaseBrowseSpec, started: Date) async throws -> PluginQueryResult { + let client = try requireFirestore() + let target = try FirestoreCollectionTarget(table: spec.table) + if !spec.filters.isEmpty, columnTypes(spec.table).isEmpty { + _ = try await browseFirestore(FirebaseBrowseSpec(table: spec.table, limit: 20, offset: 0), started: started) + } + let types = columnTypes(spec.table) + let body = try FirestoreQueryBuilder.structuredQuery(spec: spec, target: target, documentRoot: client.documentRoot, + typeOf: { types[$0] }) + let documents = try await client.runQuery(parent: target.parentPath, body: body, + operation: String(format: String(localized: "read %@"), spec.table)) + let sorted = FirestoreQueryBuilder.sortedFields(spec) + let notice = sorted.isEmpty ? nil : String( + format: String(localized: "Sorted by %@. Documents without that field are not shown."), + sorted.joined(separator: ", ") + ) + return firestoreResult(FirestoreRowSet(documents: documents, target: target), table: spec.table, started: started, notice: notice) + } + + func firestoreResult(_ rows: FirestoreRowSet, table: String?, started: Date, notice: String? = nil) -> PluginQueryResult { + if let table { + rememberColumnTypes(table, columns: rows.columns, types: rows.columnTypes) + } + rememberCellTypes(rows.cellTypes) + var result = PluginQueryResult( + columns: rows.columns, + columnTypeNames: rows.columnTypes, + rows: rows.rows.map { $0.map(Self.cell) }, + rowsAffected: 0, + executionTime: elapsed(since: started), + statusMessage: notice + ) + result.rowLocators = rows.locators + result.absentCells = rows.absentCells + return result + } + + private func browseRealtimeDatabase(_ spec: FirebaseBrowseSpec, started: Date) async throws -> PluginQueryResult { + let client = try requireRealtimeDatabase() + if !spec.filters.isEmpty, columnTypes(spec.table).isEmpty { + _ = try await browseRealtimeDatabase(FirebaseBrowseSpec(table: spec.table, limit: 20, offset: 0), started: started) + } + let types = columnTypes(spec.table) + let plan = try RealtimeDatabaseQueryPlanner.plan(spec: spec, typeOf: { types[$0] }) + let node = Self.nodePath(spec.table) + let children = try await client.children(node, plan: plan) + return databaseResult(RealtimeDatabaseRowSet(children: children, nodePath: node), table: spec.table, started: started) + } + + func databaseResult(_ rows: RealtimeDatabaseRowSet, table: String?, started: Date) -> PluginQueryResult { + if let table { + rememberColumnTypes(table, columns: rows.columns, types: rows.columnTypes) + } + rememberCellTypes(rows.cellTypes) + var result = PluginQueryResult( + columns: rows.columns, + columnTypeNames: rows.columnTypes, + rows: rows.rows.map { $0.map(PluginCellValue.fromOptional) }, + rowsAffected: 0, + executionTime: elapsed(since: started) + ) + result.rowLocators = rows.locators + result.absentCells = rows.absentCells + return result + } + + static func cell(_ display: FirestoreCell.Display) -> PluginCellValue { + switch display { + case .null: return .null + case .text(let text): return .text(text) + case .bytes(let data): return .bytes(data) + } + } + + // MARK: - Console: Firestore + + private func executeFirestore(_ request: FirebaseRequest, started: Date) async throws -> PluginQueryResult { + let client = try requireFirestore() + switch request.method { + case .get: + return try await firestoreGet(request, client: client, started: started) + case .query: + let body = try FirebaseJSON.parse(request.body ?? "") + guard body["structuredQuery"] != nil else { + throw FirebaseError.configuration(String(localized: "QUERY needs a JSON body with a structuredQuery.")) + } + let documents = try await client.runQuery(parent: request.path, body: body, operation: "QUERY") + return firestoreResult(FirestoreRowSet(documents: documents, target: try Self.pathTarget(documents)), table: nil, started: started) + case .post: + let fields = try FirestoreDocumentJSON.fields(from: request.body ?? "") + let created = try await client.createDocument(collectionPath: request.path, documentId: request.queryValue("documentId"), fields: fields) + return writeResult(created.path, started: started) + case .patch: + guard FirestorePath.isDocument(request.path) else { + throw FirebaseError.configuration(String(localized: "PATCH needs a document path, such as users/alice.")) + } + let fields = try FirestoreDocumentJSON.fields(from: request.body ?? "") + let mask = request.queryValue("updateMask").map(FirestoreQueryBuilder.maskFields) + try await client.patchDocument(request.path, fields: fields, mask: mask) + return writeResult(request.path, started: started) + case .delete: + guard FirestorePath.isDocument(request.path) else { + throw FirebaseError.configuration(String(localized: "DELETE needs a document path, such as users/alice.")) + } + try await client.deleteDocument(request.path) + return writeResult(request.path, started: started) + case .put: + throw FirebaseError.unsupported(String(localized: "Firestore has no PUT. Use POST to create a document and PATCH to change one.")) + } + } + + private func firestoreGet(_ request: FirebaseRequest, client: FirestoreClient, started: Date) async throws -> PluginQueryResult { + let listsCollections = request.path.isEmpty + || (FirestorePath.isDocument(request.path) && request.queryValue("collections") != nil) + if listsCollections { + let ids = try await client.listCollectionIds(parent: request.path) + return PluginQueryResult(columns: ["collection"], columnTypeNames: ["string"], rows: ids.map { [.text($0)] }, + rowsAffected: 0, executionTime: elapsed(since: started)) + } + if FirestorePath.isDocument(request.path) { + guard let document = try await client.getDocument(request.path) else { + throw FirebaseError.notFound(String(format: String(localized: "No document at %@."), request.path)) + } + let target = try FirestoreCollectionTarget(table: FirestorePath.split(collectionPath: request.path).parent) + return firestoreResult(FirestoreRowSet(documents: [document], target: target), table: nil, started: started) + } + let limit = request.queryValue("pageSize").flatMap(Int.init) ?? Self.consolePageSize + let spec = FirebaseBrowseSpec(table: request.path, limit: limit, offset: 0) + return try await browseFirestore(spec, started: started) + } + + /// A console query can span parents, so its rows are named by full path. + private static func pathTarget(_ documents: [FirestoreDocument]) throws -> FirestoreCollectionTarget { + let id = documents.first.map { FirestorePath.split(collectionPath: FirestorePath.split(collectionPath: $0.path).parent).collectionId } + return try FirestoreCollectionTarget(table: FirestoreCollectionTarget.groupName(id ?? "documents")) + } + + // MARK: - Console: Realtime Database + + private func executeRealtimeDatabase(_ request: FirebaseRequest, started: Date) async throws -> PluginQueryResult { + try Self.refuseWholeDatabaseWrite(request) + let client = try requireRealtimeDatabase() + switch request.method { + case .get: + let json = try await client.get(request.path, query: request.query) + if case .object(let members) = json { + let children = members.map { (key: $0.key, value: $0.value) } + .sorted { RealtimeDatabaseOrdering.keyPrecedes($0.key, $1.key) } + return databaseResult(RealtimeDatabaseRowSet(children: children, nodePath: request.path), table: nil, started: started) + } + return databaseResult(RealtimeDatabaseRowSet(children: json == .null ? [] : [("", json)], nodePath: request.path), + table: nil, started: started) + case .put: + try await client.put(request.path, value: try FirebaseJSON.parse(request.body ?? "")) + return writeResult("/" + request.path, started: started) + case .patch: + guard case .object(let values) = try FirebaseJSON.parse(request.body ?? "") else { + throw FirebaseError.configuration(String(localized: "PATCH needs a JSON object of the children to change.")) + } + try await client.patch(request.path, values: values) + return writeResult("/" + request.path, started: started) + case .post: + let key = try await client.push(request.path, value: try FirebaseJSON.parse(request.body ?? "")) + return writeResult(RealtimeDatabasePath.join("/" + request.path, key ?? ""), started: started) + case .delete: + try await client.delete(request.path) + return writeResult("/" + request.path, started: started) + case .query: + throw FirebaseError.unsupported(String(localized: "QUERY is for Firestore. Use GET with orderBy, equalTo, startAt or limitToFirst.")) + } + } + + /// `PUT /` replaces every node, so it is refused like `DELETE /`. + static func refuseWholeDatabaseWrite(_ request: FirebaseRequest) throws { + guard request.path.isEmpty else { return } + switch request.method { + case .delete: + throw FirebaseError.unsupported(String(localized: "Deleting the whole database is not offered. Delete its children one at a time.")) + case .put: + throw FirebaseError.unsupported(String(localized: "Replacing the whole database is not offered. Write its children one at a time.")) + case .get, .query, .patch, .post: + return + } + } + + private func writeResult(_ path: String, started: Date) -> PluginQueryResult { + PluginQueryResult(columns: ["path"], columnTypeNames: ["string"], rows: [[.text(path)]], + rowsAffected: 1, executionTime: elapsed(since: started)) + } + + // MARK: - Export + + func streamRows(query: String) -> AsyncThrowingStream { + AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + let task = Task { + do { + if case .browse(let spec)? = try? FirebaseStatement.parse(query), spec.limit == 0 { + try await self.export(spec, continuation: continuation) + } else { + let result = try await self.execute(query: query) + continuation.yield(.header(PluginStreamHeader(columns: result.columns, columnTypeNames: result.columnTypeNames, + estimatedRowCount: nil))) + if !result.rows.isEmpty { continuation.yield(.rows(result.rows)) } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } + + private func export(_ spec: FirebaseBrowseSpec, continuation: AsyncThrowingStream.Continuation) async throws { + var header: [String]? + var types: [String] = [] + try await forEachExportPage(spec) { columns, columnTypes, rows in + if header == nil { + header = columns + [Self.extraColumn] + types = columnTypes + ["map"] + continuation.yield(.header(PluginStreamHeader(columns: header ?? [], columnTypeNames: types, estimatedRowCount: nil))) + } + let fixed = header ?? [] + continuation.yield(.rows(rows.map { row in Self.align(row, columns: columns, to: fixed) })) + } + if header == nil { + continuation.yield(.header(PluginStreamHeader(columns: [Self.extraColumn], columnTypeNames: ["map"], estimatedRowCount: nil))) + } + } + + static func align(_ row: [PluginCellValue], columns: [String], to header: [String]) -> [PluginCellValue] { + var byName: [String: PluginCellValue] = [:] + for (column, value) in zip(columns, row) { byName[column] = value } + var aligned = header.dropLast().map { byName.removeValue(forKey: $0) ?? .null } + let extra = byName.filter { !$0.value.isNull }.mapValues { FirebaseJSON.string($0.asText ?? $0.sortKey) } + aligned.append(extra.isEmpty ? .null : .text(FirebaseJSON.object(extra).text())) + return aligned + } + + private func forEachExportPage( + _ spec: FirebaseBrowseSpec, + page: ([String], [String], [[PluginCellValue]]) throws -> Void + ) async throws { + switch variant { + case .firestore: + let client = try requireFirestore() + let target = try FirestoreCollectionTarget(table: spec.table) + let types = columnTypes(spec.table) + var cursor: String? + repeat { + try Task.checkCancellation() + let pageSpec = FirebaseBrowseSpec(table: spec.table, filters: spec.filters, logicMode: spec.logicMode, + sorts: [FirebaseSortSpec(column: FirestoreQueryBuilder.nameField, ascending: true)], + limit: Self.exportPageSize, offset: 0) + var body = try FirestoreQueryBuilder.structuredQuery(spec: pageSpec, target: target, documentRoot: client.documentRoot, + typeOf: { types[$0] }) + if let cursor, case .object(var outer) = body, case .object(var query)? = outer["structuredQuery"] { + query["startAt"] = .object([ + "values": .array([.object(["referenceValue": .string(FirestorePath.absolute(cursor, documentRoot: client.documentRoot))])]), + "before": .bool(false) + ]) + outer["structuredQuery"] = .object(query) + body = .object(outer) + } + let documents = try await client.runQuery(parent: target.parentPath, body: body, + operation: String(format: String(localized: "export %@"), spec.table)) + guard !documents.isEmpty else { return } + let rows = FirestoreRowSet(documents: documents, target: target) + try page(rows.columns, rows.columnTypes, rows.rows.map { $0.map(Self.cell) }) + cursor = documents.count == Self.exportPageSize ? documents.last?.path : nil + } while cursor != nil + case .realtimeDatabase: + guard spec.filters.isEmpty else { + let result = try await browse(spec) + try page(result.columns, result.columnTypeNames, result.rows) + return + } + let client = try requireRealtimeDatabase() + let node = Self.nodePath(spec.table) + var lastKey: String? + repeat { + try Task.checkCancellation() + var bounds: [String: String] = [:] + if let lastKey { bounds["startAfter"] = FirebaseJSON.string(lastKey).text() } + let plan = RealtimeDatabaseReadPlan(order: .key, ascending: true, bounds: bounds, + fetchCount: Self.exportPageSize, dropCount: 0) + let children = try await client.children(node, plan: plan) + guard !children.isEmpty else { return } + let rows = RealtimeDatabaseRowSet(children: children, nodePath: node) + try page(rows.columns, rows.columnTypes, rows.rows.map { $0.map(PluginCellValue.fromOptional) }) + lastKey = children.count == Self.exportPageSize ? children.last?.key : nil + } while lastKey != nil + } + } +} diff --git a/Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Metadata.swift b/Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Metadata.swift new file mode 100644 index 0000000000..2cd43b56d8 --- /dev/null +++ b/Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Metadata.swift @@ -0,0 +1,173 @@ +import Foundation +import TableProFirebaseCore +import TableProPluginKit + +extension FirebasePluginDriver { + /// Firestore cannot list every subcollection, so they are found under the first documents of + /// each root collection. That costs up to 90 requests, so a sidebar refresh reuses the result. + static let groupDiscoveryCollections = 30 + static let groupDiscoveryDocuments = 2 + static let groupDiscoveryLifetime: TimeInterval = 600 + /// A root with more children than this holds records, not tables, and is browsed through `/`. + static let rootKeyTableLimit = 500 + static let rootTableName = "/" + static let sampleSize = 50 + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + switch variant { + case .firestore: + return try await firestoreTables() + case .realtimeDatabase: + let keys = try await requireRealtimeDatabase().childKeys("") + var tables = [PluginTableInfo(name: Self.rootTableName, type: "TABLE", comment: String(localized: "The root of the database"))] + if keys.count <= Self.rootKeyTableLimit { + tables += keys.map { PluginTableInfo(name: $0, type: "TABLE") } + } + return tables + } + } + + private func firestoreTables() async throws -> [PluginTableInfo] { + let client = try requireFirestore() + let roots = try await client.listCollectionIds(parent: "") + let groups: [String] + if let cached = cachedCollectionGroups(maxAge: Self.groupDiscoveryLifetime) { + groups = cached.filter { !roots.contains($0) } + } else { + groups = await discoverCollectionGroups(roots: roots, client: client) + rememberCollectionGroups(groups) + } + let groupComment = String(localized: "Every collection with this ID, at any depth") + return roots.map { PluginTableInfo(name: $0, type: "TABLE") } + + groups.map { PluginTableInfo(name: FirestoreCollectionTarget.groupName($0), type: "TABLE", comment: groupComment) } + } + + private func discoverCollectionGroups(roots: [String], client: FirestoreClient) async -> [String] { + let rootSet = Set(roots) + let found = await withTaskGroup(of: [String].self) { group in + for root in roots.prefix(Self.groupDiscoveryCollections) { + group.addTask { + let body = FirebaseJSON.object(["structuredQuery": .object([ + "select": .object(["fields": .array([.object(["fieldPath": .string(FirestoreQueryBuilder.nameField)])])]), + "from": .array([.object(["collectionId": .string(root)])]), + "limit": .int(Int64(Self.groupDiscoveryDocuments)) + ])]) + guard let documents = try? await client.runQuery(parent: "", body: body, operation: root) else { return [] } + var ids: [String] = [] + for document in documents { + ids += (try? await client.listCollectionIds(parent: document.path, limit: 100)) ?? [] + } + return ids + } + } + var all: Set = [] + for await ids in group { all.formUnion(ids) } + return all + } + return found.subtracting(rootSet).sorted() + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + let sample = try await browse(FirebaseBrowseSpec(table: table, limit: Self.sampleSize, offset: 0)) + let keyColumn = variant == .firestore ? FirebaseColumns.documentName : FirebaseColumns.key + return zip(sample.columns, sample.columnTypeNames).map { name, type in + PluginColumnInfo(name: name, dataType: type, isNullable: name != keyColumn, isPrimaryKey: name == keyColumn) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + let keyColumn = variant == .firestore ? FirebaseColumns.documentName : FirebaseColumns.key + return [PluginIndexInfo(name: keyColumn, columns: [keyColumn], isUnique: true, isPrimary: true, type: "PRIMARY KEY")] + } + + /// Neither engine has a schema, so this describes the fields a sample of documents holds. + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let sample = try await browse(FirebaseBrowseSpec(table: table, limit: Self.sampleSize, offset: 0)) + let fields = Dictionary(zip(sample.columns, sample.columnTypeNames).map { ($0, FirebaseJSON.string($1)) }, + uniquingKeysWith: { first, _ in first }) + let label = variant == .firestore ? "collection" : "path" + return FirebaseJSON.object([ + label: .string(table), + "sampledRows": .int(Int64(sample.rows.count)), + "fields": .object(fields) + ]).text(pretty: true) + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table, engine: serverVersion ?? "Firebase") + } + + // MARK: - Browse statements + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + buildFilteredQuery( + table: table, schema: nil, queryFilters: [], logicMode: "AND", + sortColumns: sortColumns, columns: columns, limit: limit, offset: offset, columnKinds: [:] + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + queryFilters filters: [PluginQueryFilter], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int, + columnKinds: [String: PluginColumnKind] + ) -> String? { + let sorts = sortColumns.compactMap { sort -> FirebaseSortSpec? in + guard sort.columnIndex >= 0, sort.columnIndex < columns.count else { return nil } + return FirebaseSortSpec(column: columns[sort.columnIndex], ascending: sort.ascending) + } + let specs = filters.map { FirebaseFilterSpec(column: $0.column, op: $0.op, value: $0.value, secondValue: $0.secondValue) } + return FirebaseStatement.encode(FirebaseBrowseSpec( + table: table, filters: specs, logicMode: logicMode, sorts: sorts, limit: limit, offset: offset + )) + } + + // MARK: - Counting + + func fetchExactRowCount( + table: String, + schema: String?, + queryFilters filters: [PluginQueryFilter], + logicMode: String + ) async throws -> Int? { + let specs = filters.map { FirebaseFilterSpec(column: $0.column, op: $0.op, value: $0.value, secondValue: $0.secondValue) } + return try await count(FirebaseBrowseSpec(table: table, filters: specs, logicMode: logicMode, limit: 0, offset: 0)) + } + + func fetchFilteredRowCount(table: String, queryFilters filters: [PluginQueryFilter], logicMode: String) async throws -> Int? { + try await fetchExactRowCount(table: table, schema: nil, queryFilters: filters, logicMode: logicMode) + } + + /// The Realtime Database counts only unfiltered, from the shallow key list, so the app asks for + /// it only on request. + func count(_ spec: FirebaseBrowseSpec) async throws -> Int? { + switch variant { + case .firestore: + let client = try requireFirestore() + let target = try FirestoreCollectionTarget(table: spec.table) + let types = columnTypes(spec.table) + let body = try FirestoreQueryBuilder.countQuery(spec: spec, target: target, documentRoot: client.documentRoot, + typeOf: { types[$0] }) + return try await client.count(parent: target.parentPath, body: body, + operation: String(format: String(localized: "count %@"), spec.table)) + case .realtimeDatabase: + guard spec.filters.isEmpty else { return nil } + return try await requireRealtimeDatabase().childKeys(Self.nodePath(spec.table)).count + } + } + + static func nodePath(_ table: String) -> String { + table == rootTableName ? "" : RealtimeDatabasePath.normalized(table) + } +} diff --git a/Plugins/FirebaseDriverPlugin/FirebasePluginDriver.swift b/Plugins/FirebaseDriverPlugin/FirebasePluginDriver.swift new file mode 100644 index 0000000000..ace0cbad58 --- /dev/null +++ b/Plugins/FirebaseDriverPlugin/FirebasePluginDriver.swift @@ -0,0 +1,307 @@ +import Foundation +import os +import TableProFirebaseCore +import TableProLogRedaction +import TableProPluginKit + +internal final class FirebasePluginDriver: PluginDatabaseDriver, @unchecked Sendable { + static let logger = Logger(subsystem: "com.TablePro", category: "FirebasePluginDriver") + + /// Field types of this many documents are kept for typing edits: the pages a user is editing, + /// without growing without bound. + static let cellTypeCacheLimit = 20_000 + + let config: DriverConnectionConfig + let queryTimeout = HttpQueryTimeoutBox() + private let lock = NSLock() + private var session: FirebaseSession? + private var columnTypeCache: [String: [String: String]] = [:] + private var cellTypeCache: [String: [String: String]] = [:] + private var cellTypeOrder: [String] = [] + private var collectionGroups: [String: (names: [String], found: Date)] = [:] + + init(config: DriverConnectionConfig) { + self.config = config + } + + var variant: FirebaseVariant { + config.additionalFields[FirebaseFieldID.driverVariant] == FirebaseVariant.realtimeDatabase.rawValue + ? .realtimeDatabase : .firestore + } + + var serverVersion: String? { + variant == .firestore ? "Cloud Firestore" : "Firebase Realtime Database" + } + + var capabilities: PluginCapabilities { [.cancelQuery] } + var supportsTransactions: Bool { false } + var parameterStyle: ParameterStyle { .questionMark } + + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + // MARK: - Lifecycle + + func connect() async throws { + let resolved = try FirebaseCredentialFactory.resolve(config: config) + let settings = try FirebaseConnectionSettings.parse(fields: config.additionalFields, credentialProject: resolved.projectHint) + let connectMilliseconds = PluginConnectTimeout.milliseconds( + in: config.additionalFields, + default: Int(HttpQueryTimeout().requestTimeoutInterval * 1_000) + ) + let deadline = PluginConnectDeadline(milliseconds: connectMilliseconds) + let transport = URLSessionFirebaseTransport(resourceTimeout: HttpQueryTimeout.sessionResourceTimeout) + let timeoutBox = queryTimeout + let timeout: @Sendable () -> TimeInterval = { timeoutBox.requestTimeoutInterval } + let connectTimeout: @Sendable () -> TimeInterval = { deadline.remainingSeconds() } + + let session: FirebaseSession + switch settings.variant { + case .firestore: + session = try await connectFirestore(settings: settings, transport: transport, credential: resolved.credential, + connectTimeout: connectTimeout, timeout: timeout) + case .realtimeDatabase: + session = try await connectRealtimeDatabase(settings: settings, transport: transport, credential: resolved.credential, + connectTimeout: connectTimeout, timeout: timeout) + } + lock.withLock { + self.session = session + resetCachesLocked() + } + } + + /// The probe runs under the connect deadline and the client after it under the query timeout. + private func connectFirestore( + settings: FirebaseConnectionSettings, + transport: URLSessionFirebaseTransport, + credential: FirebaseCredential, + connectTimeout: @escaping @Sendable () -> TimeInterval, + timeout: @escaping @Sendable () -> TimeInterval + ) async throws -> FirebaseSession { + guard let projectId = settings.projectId, let baseURL = settings.firestoreBaseURL else { + throw FirebaseError.configuration(String(localized: "Enter a Project ID.")) + } + let probe = FirestoreClient(projectId: projectId, database: settings.firestoreDatabase, baseURL: baseURL, + transport: transport, credential: credential, timeout: connectTimeout) + try await probe.ping() + let client = FirestoreClient(projectId: projectId, database: settings.firestoreDatabase, baseURL: baseURL, + transport: transport, credential: credential, timeout: timeout) + return FirebaseSession(settings: settings, transport: transport, credential: credential, timeout: timeout, + firestore: client, database: nil, instances: [:]) + } + + private func connectRealtimeDatabase( + settings: FirebaseConnectionSettings, + transport: URLSessionFirebaseTransport, + credential: FirebaseCredential, + connectTimeout: @escaping @Sendable () -> TimeInterval, + timeout: @escaping @Sendable () -> TimeInterval + ) async throws -> FirebaseSession { + var instances: [String: URL] = [:] + if settings.emulatorHost == nil, let projectId = settings.projectId { + let lister = RealtimeDatabaseInstanceLister(transport: transport, credential: credential, timeout: connectTimeout) + do { + for url in try await lister.instances(projectId: projectId) { + instances[RealtimeDatabaseClient.instanceName(of: url)] = url + } + } catch { + Self.logger.info("Could not list Realtime Database instances: \(LogRedaction.publicDescription(of: error), privacy: .public) \(error.localizedDescription, privacy: .private)") + } + } + let url = settings.databaseURL + ?? settings.projectId.flatMap { instances["\($0)-default-rtdb"] } + ?? instances.values.min { $0.absoluteString < $1.absoluteString } + ?? settings.defaultDatabaseURL + guard let url else { + throw FirebaseError.configuration(String(localized: "Enter a Database URL.")) + } + let probe = RealtimeDatabaseClient(instanceURL: url, transport: transport, credential: credential, timeout: connectTimeout) + try await probe.locate() + let resolved = probe.currentURL + instances[RealtimeDatabaseClient.instanceName(of: resolved)] = resolved + let client = RealtimeDatabaseClient(instanceURL: resolved, transport: transport, credential: credential, timeout: timeout) + return FirebaseSession(settings: settings, transport: transport, credential: credential, timeout: timeout, + firestore: nil, database: client, instances: instances) + } + + func disconnect() { + lock.withLock { + session?.transport.cancelAll() + session = nil + } + } + + func ping() async throws { + let session = try requireSession() + if let firestore = session.firestore { + try await firestore.ping() + } else if let database = session.database { + try await database.ping() + } + } + + func cancelQuery() throws { + lock.withLock { session?.transport.cancelAll() } + } + + func applyQueryTimeout(_ seconds: Int) async throws { + queryTimeout.set(serverTimeoutSeconds: seconds) + } + + // MARK: - Databases + + var currentDatabase: String? { + guard let session = lock.withLock({ session }) else { return nil } + return session.firestore?.currentDatabase ?? session.database?.instanceName + } + + func fetchDatabases() async throws -> [String] { + let session = try requireSession() + if let firestore = session.firestore { + do { + let names = try await firestore.listDatabases() + return names.isEmpty ? [firestore.currentDatabase] : names + } catch let error as FirebaseError { + Self.logger.info("Could not list Firestore databases: \(LogRedaction.publicDescription(of: error), privacy: .public) \(error.localizedDescription, privacy: .private)") + return [firestore.currentDatabase] + } + } + return session.instances.keys.sorted() + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func switchDatabase(to database: String) async throws { + let session = try requireSession() + if let firestore = session.firestore { + firestore.switchDatabase(to: database) + } else if let client = session.database { + guard let url = session.instances[database] else { + throw FirebaseError.notFound(String(format: String(localized: "No Realtime Database instance named %@."), database)) + } + client.switchInstance(to: url) + } + lock.withLock { resetCachesLocked() } + } + + private func resetCachesLocked() { + columnTypeCache.removeAll() + cellTypeCache.removeAll() + cellTypeOrder.removeAll() + collectionGroups.removeAll() + } + + // MARK: - Shared state + + func requireSession() throws -> FirebaseSession { + guard let session = lock.withLock({ session }) else { throw FirebaseError.notConnected } + return session + } + + func requireFirestore() throws -> FirestoreClient { + guard let client = try requireSession().firestore else { throw FirebaseError.notConnected } + return client + } + + func requireRealtimeDatabase() throws -> RealtimeDatabaseClient { + guard let client = try requireSession().database else { throw FirebaseError.notConnected } + return client + } + + func rememberColumnTypes(_ table: String, columns: [String], types: [String]) { + lock.withLock { + var known = columnTypeCache[table] ?? [:] + for (column, type) in zip(columns, types) { known[column] = type } + columnTypeCache[table] = known + } + } + + func columnTypes(_ table: String) -> [String: String] { + lock.withLock { columnTypeCache[table] ?? [:] } + } + + func rememberCellTypes(_ types: [String: [String: String]]) { + lock.withLock { + for (locator, fields) in types { + if cellTypeCache.updateValue(fields, forKey: locator) == nil { + cellTypeOrder.append(locator) + } + } + let overflow = cellTypeOrder.count - Self.cellTypeCacheLimit + if overflow > 0 { + for locator in cellTypeOrder.prefix(overflow) { cellTypeCache[locator] = nil } + cellTypeOrder.removeFirst(overflow) + } + } + } + + /// The host's copy stands in for a column this connection has not read, as after a reconnect. + func writeColumnTypes(_ table: String, host: [String: String]) -> [String: String] { + host.mapValues { $0.lowercased() }.merging(columnTypes(table)) { _, read in read } + } + + /// The type the cell held when it was read, unless it held null, then the column's type. + func writeType(_ column: String, locator: String?, columnTypes: [String: String]) -> String? { + let held = locator.flatMap { locator in lock.withLock { cellTypeCache[locator]?[column] } } + if let held, held != FirestoreTypeName.null { + return held + } + return columnTypes[column] + } + + func cachedCollectionGroups(maxAge: TimeInterval) -> [String]? { + let database = currentDatabase ?? "" + return lock.withLock { + guard let entry = collectionGroups[database], Date().timeIntervalSince(entry.found) < maxAge else { return nil } + return entry.names + } + } + + func rememberCollectionGroups(_ names: [String]) { + let database = currentDatabase ?? "" + lock.withLock { collectionGroups[database] = (names, Date()) } + } + + // MARK: - Table operations + + func defaultExportQuery(table: String) -> String? { + FirebaseStatement.encode(FirebaseBrowseSpec(table: table, limit: 0, offset: 0)) + } + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + nil + } + + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { + nil + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + throw FirebaseError.unsupported(String(localized: "Firebase has no views.")) + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + [] + } + + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { + nil + } + + func elapsed(since start: Date) -> TimeInterval { + Date().timeIntervalSince(start) + } +} + +struct FirebaseSession: Sendable { + let settings: FirebaseConnectionSettings + let transport: URLSessionFirebaseTransport + let credential: FirebaseCredential + let timeout: @Sendable () -> TimeInterval + let firestore: FirestoreClient? + let database: RealtimeDatabaseClient? + let instances: [String: URL] +} diff --git a/Plugins/FirebaseDriverPlugin/Info.plist b/Plugins/FirebaseDriverPlugin/Info.plist new file mode 100644 index 0000000000..5134db0ac1 --- /dev/null +++ b/Plugins/FirebaseDriverPlugin/Info.plist @@ -0,0 +1,15 @@ + + + + + TableProMinAppVersion + 0.80.0 + TableProPluginKitVersion + 37 + TableProProvidesDatabaseTypeIds + + Firestore + Firebase Realtime Database + + + diff --git a/TablePro/Assets.xcassets/firebase-icon.imageset/Contents.json b/TablePro/Assets.xcassets/firebase-icon.imageset/Contents.json new file mode 100644 index 0000000000..169cdef779 --- /dev/null +++ b/TablePro/Assets.xcassets/firebase-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "firebase.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/TablePro/Assets.xcassets/firebase-icon.imageset/firebase.svg b/TablePro/Assets.xcassets/firebase-icon.imageset/firebase.svg new file mode 100644 index 0000000000..564e2a3e48 --- /dev/null +++ b/TablePro/Assets.xcassets/firebase-icon.imageset/firebase.svg @@ -0,0 +1 @@ +Firebase \ No newline at end of file diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+FirebaseDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+FirebaseDefaults.swift new file mode 100644 index 0000000000..7b1ef747a5 --- /dev/null +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+FirebaseDefaults.swift @@ -0,0 +1,194 @@ +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + /// The Realtime Database is the plugin's variant, so its entry here is the one the app uses even + /// once the plugin loads. + func firebasePluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [ + ("Firestore", PluginMetadataSnapshot( + displayName: "Cloud Firestore", iconName: "firebase-icon", defaultPort: 0, + requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: [], postConnectActions: [], + brandColorHex: "#FFA000", + queryLanguageName: "REST", editorLanguage: .javascript, + connectionMode: .apiOnly, supportsDatabaseSwitching: true, + capabilities: firebaseCapabilities(realtimeDatabase: false), + schema: firebaseSchema(tableEntityName: "Collections", primaryKey: "__name__", defaultGroupName: "(default)"), + editor: firebaseEditor, + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: firebaseConnectionFields(realtimeDatabase: false), + category: .document, + tagline: String(localized: "Serverless document database from Google"), + hidesBuiltInPassword: true, + hidesBuiltInDatabase: true + ) + )), + ("Firebase Realtime Database", PluginMetadataSnapshot( + displayName: "Firebase Realtime Database", iconName: "firebase-icon", defaultPort: 0, + requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: [], postConnectActions: [], + brandColorHex: "#FFA000", + queryLanguageName: "REST", editorLanguage: .javascript, + connectionMode: .apiOnly, supportsDatabaseSwitching: true, + capabilities: firebaseCapabilities(realtimeDatabase: true), + schema: firebaseSchema(tableEntityName: "Nodes", primaryKey: "$key", defaultGroupName: "default"), + editor: firebaseEditor, + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: firebaseConnectionFields(realtimeDatabase: true), + category: .document, + tagline: String(localized: "JSON tree database that syncs in real time"), + hidesBuiltInPassword: true, + hidesBuiltInDatabase: true + ) + )) + ] + } + + /// The Realtime Database filters on one field at a time and counts only by listing every key. + private func firebaseCapabilities(realtimeDatabase: Bool) -> PluginMetadataSnapshot.CapabilityFlags { + PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsDocumentEditing: true, + supportsFieldRemoval: true, + supportsAddColumn: false, + supportsModifyColumn: false, + supportsDropColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false, + supportsOpportunisticTLS: false, + exactRowCountIsFullScan: realtimeDatabase, + supportsMatchAnyFilters: !realtimeDatabase, + columnsAreSampled: true, + supportsPrincipalConnectionLimit: false + ) + } + + private func firebaseSchema(tableEntityName: String, primaryKey: String, defaultGroupName: String) -> PluginMetadataSnapshot.SchemaInfo { + PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", + defaultGroupName: defaultGroupName, + tableEntityName: tableEntityName, + containerEntityName: "Database", + defaultPrimaryKeyColumn: primaryKey, + immutableColumns: ["__name__", "$key"], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .byDatabase, + structureColumnFields: [.name, .type, .nullable] + ) + } + + private var firebaseEditor: PluginMetadataSnapshot.EditorConfig { + PluginMetadataSnapshot.EditorConfig( + sqlDialect: nil, + statementCompletions: firebaseCompletions, + columnTypesByCategory: firebaseColumnTypes + ) + } +} + +private let firebaseCompletions: [CompletionEntry] = [ + CompletionEntry(label: "GET collection", insertText: "GET users?pageSize=20"), + CompletionEntry(label: "GET document", insertText: "GET users/alice"), + CompletionEntry( + label: "QUERY", + insertText: """ + QUERY { + "structuredQuery": { + "from": [{"collectionId": "users"}], + "where": {"fieldFilter": {"field": {"fieldPath": "age"}, "op": "GREATER_THAN", "value": {"integerValue": "30"}}}, + "limit": 20 + } + } + """ + ), + CompletionEntry(label: "PATCH", insertText: "PATCH users/alice?updateMask=age {\"age\": 31}"), + CompletionEntry(label: "POST", insertText: "POST users {\"name\": \"Bob\"}"), + CompletionEntry(label: "DELETE", insertText: "DELETE users/alice"), + CompletionEntry(label: "GET node", insertText: "GET /chats?orderBy=\"$key\"&limitToFirst=20"), + CompletionEntry(label: "GET shallow", insertText: "GET /?shallow=true"), + CompletionEntry(label: "PUT node", insertText: "PUT /config/maintenance false") +] + +private let firebaseColumnTypes: [String: [String]] = [ + "Text": ["string", "reference"], + "Numeric": ["integer", "double", "number"], + "Boolean": ["boolean"], + "Date": ["timestamp"], + "Structured": ["map", "array", "object", "geopoint"], + "Binary": ["bytes"] +] + +func firebaseConnectionFields(realtimeDatabase: Bool) -> [ConnectionField] { + let authMethod = "fbAuthMethod" + var fields: [ConnectionField] = [ + ConnectionField( + id: authMethod, + label: String(localized: "Auth Method"), + defaultValue: "serviceAccount", + fieldType: .dropdown(options: [ + .init(value: "serviceAccount", label: "Service Account Key"), + .init(value: "adc", label: "Application Default Credentials"), + .init(value: "emulator", label: "Emulator (no credentials)") + ]), + section: .authentication + ), + ConnectionField( + id: "fbServiceAccountJson", + label: String(localized: "Service Account Key"), + placeholder: "File path or paste JSON", + required: true, + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: authMethod, values: ["serviceAccount"]) + ).withHidesUsername(true), + ConnectionField( + id: "fbProjectId", + label: String(localized: "Project ID"), + placeholder: "Taken from the key when empty", + section: .authentication + ) + ] + if realtimeDatabase { + fields.append(ConnectionField( + id: "fbDatabaseURL", + label: String(localized: "Database URL"), + placeholder: "https://..firebasedatabase.app", + section: .authentication + )) + } else { + fields.append(ConnectionField( + id: "fbDatabaseId", + label: String(localized: "Database"), + placeholder: "(default)", + section: .authentication + )) + } + fields.append(ConnectionField( + id: "fbEmulatorHost", + label: String(localized: "Emulator Host"), + placeholder: realtimeDatabase ? "127.0.0.1:9000" : "127.0.0.1:8080", + required: true, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: authMethod, values: ["emulator"]) + )) + return fields +} diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 27eb7a8d8e..05e19de268 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1149,7 +1149,7 @@ extension PluginMetadataRegistry { + duckdbPluginDefaults(dialect: duckdbDialect, columnTypes: duckdbColumnTypes) + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() + kafkaPluginDefaults() + typesensePluginDefaults() + r2SQLPluginDefaults() - + weaviatePluginDefaults() + hanaPluginDefaults() + + weaviatePluginDefaults() + hanaPluginDefaults() + firebasePluginDefaults() } // swiftlint:enable function_body_length } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index dd87350f55..27015e7fe5 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -495,6 +495,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { reverseTypeIndex["PGlite"] = "PostgreSQL" reverseTypeIndex["ScyllaDB"] = "Cassandra" reverseTypeIndex["Turso"] = "libSQL" + reverseTypeIndex["Firebase Realtime Database"] = "Firestore" } func register(snapshot: PluginMetadataSnapshot, forTypeId typeId: String, preserveIcon: Bool = false) { @@ -824,7 +825,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { return .analytical case "Spanner": return .relational - case "MongoDB", "Elasticsearch", "SurrealDB", "Typesense", "Weaviate": + case "MongoDB", "Elasticsearch", "SurrealDB", "Typesense", "Weaviate", "Firestore", "Firebase Realtime Database": return .document case "Redis": return .keyValue @@ -867,6 +868,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { case "Kafka": return String(localized: "Event streaming platform") case "Typesense": return String(localized: "Typo-tolerant open-source search engine") case "Weaviate": return String(localized: "Open-source vector database") + case "Firestore": return String(localized: "Serverless document database from Google") + case "Firebase Realtime Database": return String(localized: "JSON tree database that syncs in real time") default: return "" } } diff --git a/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift b/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift index cbd16748d8..081fca3892 100644 --- a/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift +++ b/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift @@ -159,7 +159,7 @@ enum CatalogChangeClassifier { } private static let documentStoreTypes: Set = [ - .mongodb, .elasticsearch, .typesense, .weaviate, .etcd + .mongodb, .elasticsearch, .typesense, .weaviate, .etcd, .firestore, .firebaseRealtimeDatabase ] private static let transactionEndKeywords: Set = ["COMMIT", "END", "ROLLBACK", "ABORT"] diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index cd9ba99af2..a4bf3b2f74 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -857,6 +857,8 @@ private extension QueryClassifier { return trimmed.isEmpty ? .safe : typesenseClassification(trimmed) case .weaviate: return trimmed.isEmpty ? .safe : weaviateClassification(trimmed) + case .firestore, .firebaseRealtimeDatabase: + return trimmed.isEmpty ? .safe : firebaseClassification(trimmed, databaseType: databaseType) default: return nil } @@ -1021,4 +1023,70 @@ private extension QueryClassifier { } return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) } + + /// A grid page is a tagged spec and anything else a request line read the way the driver reads + /// it. A write that leaves a node holding nothing deletes it, so it ranks with DELETE. + static func firebaseClassification(_ trimmed: String, databaseType: DatabaseType) -> QueryClassification { + if trimmed.hasPrefix("FIREBASE_BROWSE:") { + return .safe + } + let destructive = QueryClassification(tier: .destructive, reachesFilesystemOrExecutesCode: false) + let request = firebaseRequestLine(trimmed) + switch request.verb { + case "GET", "QUERY": + return .safe + case "DELETE": + return destructive + case "PUT" where request.isRoot || firebaseHoldsNothing(request.body): + return destructive + case "PATCH" where databaseType == .firebaseRealtimeDatabase && firebaseRemovesChild(request.body): + return destructive + default: + return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) + } + } + + /// The body is the rest of the first line and every line after it, as the driver takes it. + static func firebaseRequestLine(_ trimmed: String) -> (verb: String, isRoot: Bool, body: String) { + let lines = trimmed.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + let header = lines.first.map(String.init) ?? "" + let parts = header.split(maxSplits: 2, omittingEmptySubsequences: true, whereSeparator: \.isWhitespace).map(String.init) + let target = parts.count > 1 ? String(parts[1].prefix { $0 != "?" }) : "" + let path = target.removingPercentEncoding ?? target + let inline = parts.count > 2 ? parts[2] : "" + let following = lines.count > 1 ? String(lines[1]) : "" + return ( + parts.first?.uppercased() ?? "", + path.split(separator: "/").isEmpty, + [inline, following].joined(separator: "\n") + ) + } + + static func firebaseHoldsNothing(_ body: String) -> Bool { + guard let json = firebaseJSON(body) else { return false } + return firebaseHoldsNothing(json: json) + } + + static func firebaseRemovesChild(_ body: String) -> Bool { + guard let members = firebaseJSON(body) as? [String: Any] else { return false } + return members.values.contains { firebaseHoldsNothing(json: $0) } + } + + private static func firebaseJSON(_ body: String) -> Any? { + guard let data = body.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + } + + private static func firebaseHoldsNothing(json: Any) -> Bool { + switch json { + case is NSNull: + return true + case let members as [String: Any]: + return members.values.allSatisfy { firebaseHoldsNothing(json: $0) } + case let items as [Any]: + return items.allSatisfy { firebaseHoldsNothing(json: $0) } + default: + return false + } + } } diff --git a/TablePro/Models/Connection/DatabaseType.swift b/TablePro/Models/Connection/DatabaseType.swift index ead5215fe4..0cb79fe8fb 100644 --- a/TablePro/Models/Connection/DatabaseType.swift +++ b/TablePro/Models/Connection/DatabaseType.swift @@ -51,6 +51,8 @@ extension DatabaseType { static let teradata = DatabaseType(rawValue: "Teradata") static let trino = DatabaseType(rawValue: "Trino") static let weaviate = DatabaseType(rawValue: "Weaviate") + static let firestore = DatabaseType(rawValue: "Firestore") + static let firebaseRealtimeDatabase = DatabaseType(rawValue: "Firebase Realtime Database") static let kafka = DatabaseType(rawValue: "Kafka") static let sapHana = DatabaseType(rawValue: "SAP HANA") } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index e782cbe2e0..d196056e2c 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -75,6 +75,14 @@ } } }, + "__name__ is the document's ID in this collection, such as alice." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "__name__ is the document's ID or path, as text." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "_id cannot be renamed, removed or used as a new name. MongoDB keys every document by it." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -816,6 +824,10 @@ } } }, + "\"%@\" is not a collection path." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "\"%@\" is not a DynamoDB action this editor runs" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -864,6 +876,10 @@ } } }, + "\"%@\" is not a Google Cloud project ID." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "“%@” is not a host or host:port" : { }, @@ -877,6 +893,14 @@ } } }, + "\"%@\" is not a number." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "\"%@\" is not a Realtime Database URL. It looks like https://.firebaseio.com or https://..firebasedatabase.app." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "\"%@\" is not a system color TablePro knows." : { }, @@ -892,6 +916,10 @@ } } }, + "\"%@\" is not a valid %@. Example: %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "\"%@\" is not a valid AWS region." : { "localizations" : { "fr" : { @@ -917,6 +945,14 @@ } } }, + "\"%@\" is not a valid key. Keys cannot be empty or contain . # $ [ ] or /." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "\"%@\" is not a valid path." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "\"%@\" is not a valid RDS endpoint. Use the form host or host:port." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -956,6 +992,10 @@ } } }, + "\"%@\" is not true or false." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "“%@” is still in use" : { "localizations" : { "fr" : { @@ -4225,6 +4265,14 @@ } } }, + "%@ needs %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "%@ needs a JSON body after the path." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%@ needs a table. Pass 'table'." : { "localizations" : { "fr" : { @@ -15078,6 +15126,14 @@ } } }, + "A document added to a collection group needs __name__ set to its full path." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "A document cannot be renamed. Add a new document and delete this one." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A document is one JSON object, written between { and }." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -15090,6 +15146,10 @@ } } }, + "a document path such as users/alice" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A domain's base type, or a range's subtype" : { "localizations" : { "fr" : { @@ -15208,6 +15268,10 @@ } } }, + "A Firestore document is a JSON object of fields." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A foreign key change recreates the table, and these cannot travel with it:\n\n%@\n\nSave them on their own first, then change the foreign key." : { "localizations" : { "fr" : { @@ -15502,6 +15566,10 @@ } } }, + "A new child needs at least one value. The Realtime Database does not store empty nodes." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A new database named %@ is created first." : { "localizations" : { "fr" : { @@ -15564,6 +15632,10 @@ } } }, + "A new row in a collection group needs __name__ set to the full document path, such as users/alice/orders/1." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A new version downloads in the background and installs the next time you quit TablePro." : { "localizations" : { "fr" : { @@ -15654,6 +15726,14 @@ } } }, + "A node cannot be moved. Add it under the new key and delete this one." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "a number" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A number in %@ is larger than a 64-bit integer. Write it as a decimal, such as {\"$numberDecimal\": \"12345678901234567890\"}." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -15840,6 +15920,10 @@ } } }, + "A raw Firestore filter is a JSON Filter object, such as {\"fieldFilter\": {...}}." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A raw SurrealDB filter must be one condition that only reads. Run anything else in the SurrealQL editor." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -23845,6 +23929,14 @@ } } }, + "an object" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "an object with latitude and longitude" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "An operation is already running." : { "localizations" : { "fr" : { @@ -23885,6 +23977,10 @@ } } }, + "an RFC 3339 time such as 2026-10-12T08:00:00Z" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "An SSM target is required, such as i-0123456789abcdef0" : { "localizations" : { "fr" : { @@ -30026,6 +30122,10 @@ } } }, + "base64 text" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Baseline" : { "localizations" : { "fr" : { @@ -30340,6 +30440,10 @@ } } }, + "BETWEEN needs two values." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "BETWEEN on %@ needs an upper bound." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -36591,6 +36695,10 @@ } } }, + "Choose a service account key file, or paste its JSON." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Choose a shared folder for your team's connection catalog. Teammates add this folder under Settings > %1$@ > %2$@ to see published connections." : { "localizations" : { "fr" : { @@ -45438,6 +45546,9 @@ } } } + }, + "Connect again to review the script and run it." : { + }, "Connect Anyway" : { "localizations" : { @@ -45478,9 +45589,6 @@ } } } - }, - "Connect again to review the script and run it." : { - }, "Connect from the connection's window in TablePro to review them." : { "localizations" : { @@ -53109,6 +53217,10 @@ } } }, + "count %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Count %lld" : { }, @@ -53618,6 +53730,10 @@ } } }, + "create a document in %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Create all columns" : { "localizations" : { "fr" : { @@ -59734,6 +59850,10 @@ } } }, + "delete %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Delete %d Connections" : { "localizations" : { "fr" : { @@ -60461,6 +60581,10 @@ } } }, + "DELETE needs a document path, such as users/alice." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Delete Preset" : { "localizations" : { "fr" : { @@ -61327,6 +61451,10 @@ } } }, + "Deleting the whole database is not offered. Delete its children one at a time." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Deletion protection" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -68819,6 +68947,10 @@ }, "Empty Value" : { + }, + "Emulator Host" : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Enable" : { "localizations" : { @@ -69723,6 +69855,14 @@ } } }, + "Enter a Database URL or a Project ID." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Enter a Database URL." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Enter a document." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -69807,6 +69947,14 @@ } } }, + "Enter a Project ID, or use a service account key that names one." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Enter a Project ID." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Enter a request like: GET /collections" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -69831,6 +69979,10 @@ } } }, + "Enter a request such as GET users/alice, QUERY {\"structuredQuery\": {...}}, PATCH users/alice {\"age\": 31} or DELETE users/alice." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Enter a value for the primary key column %@." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -70191,6 +70343,10 @@ } } }, + "Enter the emulator as host:port, such as 127.0.0.1:8080." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Enter the entry point the extension's documentation names." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -71829,6 +71985,10 @@ } } }, + "Every collection with this ID, at any depth" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Every column is written when none is ticked." : { "localizations" : { "fr" : { @@ -74561,6 +74721,10 @@ } } }, + "export %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Export %1$@ to %2$@" : { "localizations" : { "fr" : { @@ -81006,6 +81170,50 @@ } } }, + "Firebase answered with something other than HTTP." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Firebase has no views." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Firebase refused the credential." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Firebase returned HTTP %d." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Firestore cannot filter with %@. It supports =, !=, <, <=, >, >=, IN, NOT IN, BETWEEN, STARTS WITH, IS NULL, and CONTAINS on an array field." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Firestore compares IN and NOT IN against 1 to 30 values." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Firestore has no PUT. Use POST to create a document and PATCH to change one." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Firestore sent a document with no name." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Firestore sent a malformed %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Firestore sent a value of unknown type %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Firestore sent a value with no type." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Firewall" : { "extractionState" : "manual", "generatesSymbol" : false @@ -100046,6 +100254,14 @@ } } }, + "list database instances" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "list databases" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "List Databases" : { "localizations" : { "fr" : { @@ -100806,6 +101022,10 @@ } } }, + "list the collections of %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "List the databases on the server, sorted by name." : { "localizations" : { "fr" : { @@ -113593,6 +113813,10 @@ } } }, + "No document at %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "No duplicate rows found." : { "localizations" : { "fr" : { @@ -116027,6 +116251,10 @@ } } }, + "No Realtime Database instance named %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "No remote database file was named for this connection." : { "localizations" : { "fr" : { @@ -118683,6 +118911,10 @@ } } }, + "Not connected to Firebase." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Not connected to MongoDB" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -118951,6 +119183,10 @@ } } }, + "Not found: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Not generated" : { "localizations" : { "fr" : { @@ -129076,6 +129312,14 @@ } } }, + "PATCH needs a document path, such as users/alice." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "PATCH needs a JSON object of the children to change." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Path" : { "localizations" : { "fr" : { @@ -129668,6 +129912,10 @@ } } }, + "Permission denied: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Permission Level" : { "localizations" : { "fr" : { @@ -136651,6 +136899,10 @@ } } }, + "QUERY is for Firestore. Use GET with orderBy, equalTo, startAt or limitToFirst." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Query Issues" : { "extractionState" : "manual", "localizations" : { @@ -136692,6 +136944,10 @@ } } }, + "QUERY needs a JSON body with a structuredQuery." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Query on index %@" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -138872,6 +139128,10 @@ } } }, + "read %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Read capacity must be a whole number of at least 1" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -144432,6 +144692,10 @@ } } }, + "Replacing the whole database is not offered. Write its children one at a time." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Replacing…" : { "localizations" : { "fr" : { @@ -165422,6 +165686,10 @@ } } }, + "Sorted by %@. Documents without that field are not shown." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Sorted descending" : { "localizations" : { "fr" : { @@ -179635,6 +179903,10 @@ } } }, + "The browse request is damaged. Reopen the collection." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The bundled sample database is missing from the app." : { "localizations" : { "fr" : { @@ -181045,6 +181317,10 @@ } } }, + "The credential lacks the IAM role for this, or the security rules block it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The credential_process command for profile \"%@\" did not return valid credentials JSON." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -181213,6 +181489,10 @@ } } }, + "The data changed after it was read." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The database “%@” has an open transaction. Closing rolls it back and discards its uncommitted changes." : { "localizations" : { "fr" : { @@ -181453,6 +181733,10 @@ } } }, + "The Database URL is not valid." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The declared length is kept for reference but is not enforced." : { "localizations" : { "fr" : { @@ -181616,6 +181900,10 @@ } } }, + "The document changed after it was opened. Reopen it and edit again." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The document changed on the server after it was opened, so nothing was saved. Copy your text, then open it again." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -181650,6 +181938,14 @@ } } }, + "The document lost its __name__. Reopen it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The document was deleted after it was opened." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The document's _id is unknown, so it cannot be addressed." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -183385,6 +183681,18 @@ } } }, + "The node changed after it was opened. Reopen it and edit again." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The node changed after it was read." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The node lost its $key. Reopen it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The object types taking part in the comparison are set in Options." : { "localizations" : { "fr" : { @@ -184779,6 +185087,22 @@ } } }, + "The Realtime Database cannot filter with %@. It supports =, <, <=, >, >=, BETWEEN, STARTS WITH and IS NULL." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The Realtime Database filters on one field at a time. Remove all but one filter, or use a raw filter." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The Realtime Database sorts by one column at a time." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The Realtime Database sorts only by the column it filters on." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The redirect address is not a local callback, so pairing was refused." : { "localizations" : { "fr" : { @@ -184961,6 +185285,10 @@ } } }, + "The root of the database" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The row filter on %@ was not a single expression, so every row was exported." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -184973,6 +185301,14 @@ } } }, + "The row has no __name__, so the document it came from is unknown. Reload the collection." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The row has no $key, so the node it came from is unknown. Reload the table." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The row's primary key is not loaded, so the row cannot be found." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -187879,6 +188215,10 @@ } } }, + "The text is not valid JSON." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The text is not valid JSON. Save anyway?" : { "localizations" : { "fr" : { @@ -194580,6 +194920,10 @@ }, "This version of TablePro can no longer sync with iCloud. Update TablePro to resume." : { + }, + "This version of the Firebase driver cannot make that kind of document write. Update the plugin." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "This view can be refreshed concurrently." : { "localizations" : { @@ -209382,6 +209726,10 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "write %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Write a migration and its rollback" : { "localizations" : { "fr" : { diff --git a/TableProMobile/TableProMobile/Assets.xcassets/firebase-icon.imageset/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/firebase-icon.imageset/Contents.json new file mode 100644 index 0000000000..169cdef779 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/firebase-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "firebase.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/firebase-icon.imageset/firebase.svg b/TableProMobile/TableProMobile/Assets.xcassets/firebase-icon.imageset/firebase.svg new file mode 100644 index 0000000000..564e2a3e48 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/firebase-icon.imageset/firebase.svg @@ -0,0 +1 @@ +Firebase \ No newline at end of file diff --git a/TableProMobile/TableProWidget/Assets.xcassets/firebase-icon.imageset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/firebase-icon.imageset/Contents.json new file mode 100644 index 0000000000..169cdef779 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/firebase-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "firebase.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/firebase-icon.imageset/firebase.svg b/TableProMobile/TableProWidget/Assets.xcassets/firebase-icon.imageset/firebase.svg new file mode 100644 index 0000000000..564e2a3e48 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/firebase-icon.imageset/firebase.svg @@ -0,0 +1 @@ +Firebase \ No newline at end of file diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index 6255a8ead0..0700971f18 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -11,7 +11,7 @@ import Testing /// this registry, `snippets/driver-counts.mdx` in TableProApp/docs, and the marketing site. Nothing at runtime /// reconciles them, and by August 2026 they read 28, 27 and 25 at once. /// -/// The answer is 37, and the reason it once read 28 is worth keeping. Turso is served by +/// The answer is 39, and the reason it once read 28 is worth keeping. Turso is served by /// the libSQL plugin and was the only alias in `reverseTypeIndex` with no curated entry of its /// own, so it was the only type the picker could not offer before its plugin was installed. /// ScyllaDB is the shape every other alias already had: an alias of Cassandra with a curated @@ -24,7 +24,7 @@ import Testing /// `scripts/check-docs-against-source.py` reads this registry and holds the docs half. /// /// The count is taken from the built-in defaults rather than from `allRegisteredTypeIds()`. -/// Both answer 37 under XCTest, where no plugin bundle ever loads, but the registry is a +/// Both answer 39 under XCTest, where no plugin bundle ever loads, but the registry is a /// process-global singleton and suites that register a synthetic type run alongside this one. @MainActor struct PluginMetadataRegistryTypeCountTests { @@ -33,7 +33,7 @@ struct PluginMetadataRegistryTypeCountTests { "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "libSQL", "MariaDB", "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", "SAP HANA", "ScyllaDB", "Snowflake", "Spanner", "SQL Server", "SQLite", "SurrealDB", "Teradata", "TiDB", - "Trino", "Turso", "Typesense", "Weaviate", "OceanBase" + "Trino", "Turso", "Typesense", "Weaviate", "OceanBase", "Firestore", "Firebase Realtime Database" ] private static func builtInTypeIds() -> Set { @@ -42,10 +42,10 @@ struct PluginMetadataRegistryTypeCountTests { return Set(curated + registry) } - @Test("The app ships 37 database types before any plugin loads") + @Test("The app ships 39 database types before any plugin loads") func builtInDefaultsCoverTwentyNineTypes() { let ids = Self.builtInTypeIds() - #expect(ids.count == 37) + #expect(ids.count == 39) #expect(ids == Self.expectedTypeIds) } @@ -68,7 +68,8 @@ struct PluginMetadataRegistryTypeCountTests { "CockroachDB": "PostgreSQL", "PGlite": "PostgreSQL", "ScyllaDB": "Cassandra", - "Turso": "libSQL" + "Turso": "libSQL", + "Firebase Realtime Database": "Firestore" ] @Test("Every alias carries a curated entry of its own") diff --git a/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift b/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift index 26de96d250..ef1632788c 100644 --- a/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift +++ b/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift @@ -152,6 +152,16 @@ struct CatalogChangeClassifierTests { #expect(kinds("db.dropDatabase()", .mongodb) == .everything) } + @Test("A Firebase write refreshes the sidebar, since it can create a collection or a node") + func firebaseWritesRefresh() { + for type in [DatabaseType.firestore, .firebaseRealtimeDatabase] { + #expect(kinds("GET users", type).isEmpty) + #expect(kinds("FIREBASE_BROWSE:e30=", type).isEmpty) + #expect(kinds("POST invoices {\"total\": 1}", type) == .everything) + #expect(kinds("DELETE users/alice", type) == .everything) + } + } + @Test("Redis commands never change the catalog") func redisIsIgnored() { #expect(kinds("FLUSHDB", .redis).isEmpty) diff --git a/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift b/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift index 4bb416c14b..3cb1445390 100644 --- a/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift +++ b/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift @@ -690,6 +690,35 @@ struct QueryClassifierNonSqlTests { #expect(!QueryClassifier.isWriteQuery(search, databaseType: .weaviate)) } + @Test("Firebase request lines are tiered by their verb, and a grid page is a read") + func firebaseTiers() { + for type in [DatabaseType.firestore, .firebaseRealtimeDatabase] { + #expect(!QueryClassifier.isWriteQuery("FIREBASE_BROWSE:e30=", databaseType: type)) + #expect(!QueryClassifier.isWriteQuery("GET users/alice", databaseType: type)) + #expect(!QueryClassifier.isWriteQuery("get /chats?shallow=true", databaseType: type)) + #expect(!QueryClassifier.isWriteQuery("QUERY {\"structuredQuery\": {}}", databaseType: type)) + #expect(QueryClassifier.isWriteQuery("PATCH users/alice {\"age\": 31}", databaseType: type)) + #expect(QueryClassifier.isWriteQuery("POST users {}", databaseType: type)) + #expect(QueryClassifier.isWriteQuery("PUT /config/maintenance false", databaseType: type)) + #expect(QueryClassifier.isWriteQuery("SELECT * FROM users", databaseType: type)) + #expect(QueryClassifier.classifyTier("DELETE users/alice", databaseType: type) == .destructive) + } + } + + @Test("A Firebase write that replaces the root or leaves a node empty ranks with DELETE") + func firebaseWritesThatDelete() { + for type in [DatabaseType.firestore, .firebaseRealtimeDatabase] { + #expect(QueryClassifier.classifyTier("PUT / {\"a\": 1}", databaseType: type) == .destructive) + #expect(QueryClassifier.classifyTier("PUT %2F 1", databaseType: type) == .destructive) + #expect(QueryClassifier.classifyTier("PUT users null", databaseType: type) == .destructive) + #expect(QueryClassifier.classifyTier("PUT users\n{\"a\": {}, \"b\": [null]}", databaseType: type) == .destructive) + #expect(QueryClassifier.classifyTier("PUT users {\"a\": 1}", databaseType: type) == .write) + } + #expect(QueryClassifier.classifyTier("PATCH / {\"users\": null}", databaseType: .firebaseRealtimeDatabase) == .destructive) + #expect(QueryClassifier.classifyTier("PATCH users/a {\"n\": 1}", databaseType: .firebaseRealtimeDatabase) == .write) + #expect(QueryClassifier.classifyTier("PATCH users/a {\"n\": null}", databaseType: .firestore) == .write) + } + @Test("A console body declaring a mutation is classified the same as a bare one") func weaviateConsoleBodyIsRead() { #expect(QueryClassifier.isWriteQuery( diff --git a/TableProTests/Plugins/FirebaseConnectionFieldsTests.swift b/TableProTests/Plugins/FirebaseConnectionFieldsTests.swift new file mode 100644 index 0000000000..f77717fe99 --- /dev/null +++ b/TableProTests/Plugins/FirebaseConnectionFieldsTests.swift @@ -0,0 +1,100 @@ +import Foundation +@testable import TablePro +import TableProConnectionLibrary +import TableProFirebaseCore +import TableProPluginKit +import Testing + +struct FirebaseRegistrySnapshotTests { + private func snapshot(_ typeId: String) throws -> PluginMetadataSnapshot { + let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() + return try #require(defaults.first { $0.typeId == typeId }).snapshot + } + + @Test("Both Firebase types are API-only document engines with no SQL dialect") + func connectionShape() throws { + for typeId in ["Firestore", "Firebase Realtime Database"] { + let snapshot = try snapshot(typeId) + #expect(snapshot.connectionMode == .apiOnly) + #expect(snapshot.editor.sqlDialect == nil) + #expect(snapshot.queryLanguageName == "REST") + #expect(snapshot.supportsDatabaseSwitching) + #expect(!snapshot.supportsForeignKeys) + #expect(!snapshot.capabilities.supportsSSH) + #expect(snapshot.connection.category == .document) + #expect(snapshot.iconName == "firebase-icon") + #expect(snapshot.isDownloadable) + } + #expect(try snapshot("Firestore").schema.defaultPrimaryKeyColumn == FirestoreQueryBuilder.nameField) + #expect(try snapshot("Firebase Realtime Database").schema.defaultPrimaryKeyColumn == RealtimeDatabaseQueryPlanner.keyColumn) + } + + @Test("Both types edit whole documents and remove fields, and the Realtime Database filters on one field") + func capabilities() throws { + for typeId in ["Firestore", "Firebase Realtime Database"] { + let capabilities = try snapshot(typeId).capabilities + #expect(capabilities.supportsDocumentEditing) + #expect(capabilities.supportsFieldRemoval) + #expect(capabilities.columnsAreSampled) + } + let firestore = try snapshot("Firestore").capabilities + #expect(firestore.supportsMatchAnyFilters && !firestore.exactRowCountIsFullScan) + let database = try snapshot("Firebase Realtime Database").capabilities + #expect(!database.supportsMatchAnyFilters && database.exactRowCountIsFullScan) + } + + @Test("Neither type is ever handed SQL DROP or TRUNCATE") + func noSQLDDL() { + #expect(!SQLDDLFallbackPolicy.allowsGeneratedDDL(for: .firestore)) + #expect(!SQLDDLFallbackPolicy.allowsGeneratedDDL(for: .firebaseRealtimeDatabase)) + } + + @Test("The Realtime Database routes to the plugin that serves Firestore") + func variantRouting() { + #expect(PluginMetadataRegistry.shared.pluginTypeId(for: "Firebase Realtime Database") == "Firestore") + #expect(DatabaseType.firebaseRealtimeDatabase.pluginTypeId == "Firestore") + #expect(DatabaseType.firestore.pluginTypeId == "Firestore") + } +} + +struct FirebaseConnectionFieldsTests { + private func fields(_ typeId: String) throws -> [ConnectionField] { + let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() + return try #require(defaults.first { $0.typeId == typeId }).snapshot.connection.additionalConnectionFields + } + + @Test("The app's field ids are the ones the driver reads") + func fieldIdsMatchTheDriver() throws { + #expect(try fields("Firestore").map(\.id) == [ + FirebaseFieldID.authMethod, FirebaseFieldID.serviceAccountJson, FirebaseFieldID.projectId, + FirebaseFieldID.firestoreDatabase, FirebaseFieldID.emulatorHost + ]) + #expect(try fields("Firebase Realtime Database").map(\.id) == [ + FirebaseFieldID.authMethod, FirebaseFieldID.serviceAccountJson, FirebaseFieldID.projectId, + FirebaseFieldID.databaseURL, FirebaseFieldID.emulatorHost + ]) + } + + @Test("The auth methods offered are the ones the driver accepts, service account first") + func authMethods() throws { + let method = try #require(try fields("Firestore").first { $0.id == FirebaseFieldID.authMethod }) + #expect(method.defaultValue == FirebaseAuthMethod.serviceAccount.rawValue) + guard case .dropdown(let options) = method.fieldType else { + Issue.record("Expected a dropdown field type") + return + } + #expect(options.map(\.value) == FirebaseAuthMethod.allCases.map(\.rawValue)) + } + + @Test("The key replaces the built-in credentials, and the emulator host shows only for the emulator") + func visibility() throws { + let fields = try fields("Firestore") + let key = try #require(fields.first { $0.id == FirebaseFieldID.serviceAccountJson }) + #expect(key.isSecure) + #expect(key.hidesPassword) + #expect(fields.hidesPassword(forValues: [:])) + #expect(fields.hidesUsername(forValues: [:])) + let emulator = try #require(fields.first { $0.id == FirebaseFieldID.emulatorHost }) + #expect(emulator.visibleWhen == FieldVisibilityRule(fieldId: FirebaseFieldID.authMethod, values: ["emulator"])) + } +} diff --git a/TableProTests/Plugins/FirebaseDriverTests.swift b/TableProTests/Plugins/FirebaseDriverTests.swift new file mode 100644 index 0000000000..d1ed2e48b0 --- /dev/null +++ b/TableProTests/Plugins/FirebaseDriverTests.swift @@ -0,0 +1,374 @@ +import Foundation +import TableProFirebaseCore +import TableProPluginKit +import Testing + +private func makeDriver(realtimeDatabase: Bool = false, fields extra: [String: String] = [:]) -> FirebasePluginDriver { + var fields = extra + if realtimeDatabase { fields[FirebaseFieldID.driverVariant] = FirebaseVariant.realtimeDatabase.rawValue } + return FirebasePluginDriver(config: DriverConnectionConfig( + host: "", port: 0, username: "", password: "", database: "", additionalFields: fields + )) +} + +private func change( + _ type: PluginRowChange.ChangeType, + row: Int = 0, + cells: [(Int, String, PluginCellValue)] = [], + original: [PluginCellValue]? = nil, + absent: Set? = nil +) -> PluginRowChange { + var change = PluginRowChange( + rowIndex: row, + type: type, + cellChanges: cells.map { (columnIndex: $0.0, columnName: $0.1, oldValue: PluginCellValue.null, newValue: $0.2) }, + originalRow: original + ) + change.absentColumns = absent + return change +} + +private func statements( + _ driver: FirebasePluginDriver, + table: String, + columns: [String], + changes: [PluginRowChange], + inserted: [Int: [PluginCellValue]] = [:], + hostTypes: [String: String] = [:] +) throws -> [String] { + var context = PluginRowWriteContext() + context.columnTypeNames = hostTypes + return try driver.generateRowWrites( + table: table, schema: nil, columns: columns, primaryKeyColumns: [], + changes: changes, insertedRowData: inserted, + deletedRowIndices: Set(changes.filter { $0.type == .delete }.map(\.rowIndex)), + insertedRowIndices: Set(changes.filter { $0.type == .insert }.map(\.rowIndex)), + context: context + )?.map(\.statement) ?? [] +} + +private func restore( + _ driver: FirebasePluginDriver, + table: String, + columns: [String], + rows: [[PluginCellValue]], + absent: [Int: Set] = [:] +) -> [String]? { + driver.generateIdentityPreservingInsert( + table: table, schema: nil, columns: columns, primaryKeyColumns: [], rows: rows, absentCells: absent + )?.map(\.statement) +} + +struct FirebaseFirestoreWriteTests { + private let columns = ["__name__", "age", "name", "zip"] + + @Test("An edit is typed by the value it replaces and masked to the fields it touches") + func typedUpdate() throws { + let driver = makeDriver() + driver.rememberCellTypes(["users/alice": ["age": "integer", "zip": "string"]]) + let written = try statements(driver, table: "users", columns: columns, changes: [ + change(.update, cells: [(1, "age", .text("31")), (3, "zip", .text("02134"))], + original: [.text("alice"), .text("30"), .text("Alice"), .text("02100")]) + ]) + #expect(written == [#"PATCH users/alice?updateMask=age,zip {"age":31,"zip":"02134"}"#]) + } + + @Test("A removed field is in the mask and not in the body") + func fieldRemoval() throws { + let written = try statements(makeDriver(), table: "users", columns: columns, changes: [ + change(.update, original: [.text("alice"), .text("30"), .null, .null], absent: [2]) + ]) + #expect(written == [#"PATCH users/alice?updateMask=name {}"#]) + } + + @Test("A new row picks its ID from __name__, or lets Firestore pick one") + func inserts() throws { + let driver = makeDriver() + driver.rememberColumnTypes("users", columns: columns, types: ["string", "integer", "string", "string"]) + let named = try statements(driver, table: "users", columns: columns, changes: [change(.insert, row: 0, absent: [3])], + inserted: [0: [.text("bob"), .text("40"), .text("Bob"), .null]]) + #expect(named == [#"POST users?documentId=bob {"age":40,"name":"Bob"}"#]) + let anonymous = try statements(driver, table: "users/alice/orders", columns: ["__name__", "total"], + changes: [change(.insert, row: 1)], inserted: [1: [.text(""), .text("9.5")]]) + #expect(anonymous == [#"POST users/alice/orders {"total":9.5}"#]) + } + + @Test("A delete names the document by its path, and a group row by its full path") + func deletes() throws { + #expect(try statements(makeDriver(), table: "users", columns: columns, changes: [ + change(.delete, original: [.text("alice"), .null, .null, .null]) + ]) == ["DELETE users/alice"]) + #expect(try statements(makeDriver(), table: "*/orders", columns: ["__name__"], changes: [ + change(.delete, original: [.text("users/alice/orders/7")]) + ]) == ["DELETE users/alice/orders/7"]) + } + + @Test("A value that does not fit the field is refused with the reason, not dropped") + func refusal() { + let driver = makeDriver() + driver.rememberCellTypes(["users/alice": ["age": "integer"]]) + #expect(throws: PluginRowWriteRefusal.self) { + try statements(driver, table: "users", columns: columns, changes: [ + change(.update, cells: [(1, "age", .text("old"))], original: [.text("alice"), .text("30"), .null, .null]) + ]) + } + #expect(throws: PluginRowWriteRefusal.self) { + try statements(driver, table: "*/orders", columns: ["__name__", "total"], + changes: [change(.insert)], inserted: [0: [.text("7"), .text("1")]]) + } + } + + @Test("With nothing read on this connection, the host's column types type the edit") + func hostTypes() throws { + let edit = change(.update, cells: [(3, "zip", .text("123"))], original: [.text("alice"), .null, .null, .text("100")]) + #expect(try statements(makeDriver(), table: "users", columns: columns, changes: [edit], hostTypes: ["zip": "STRING"]) + == [#"PATCH users/alice?updateMask=zip {"zip":"123"}"#]) + #expect(try statements(makeDriver(), table: "users", columns: columns, changes: [edit]) + == [#"PATCH users/alice?updateMask=zip {"zip":123}"#]) + } + + @Test("A field that held null is typed by its column, not left to guessing") + func nullCell() throws { + let driver = makeDriver() + driver.rememberColumnTypes("users", columns: columns, types: ["string", "integer", "string", "string"]) + driver.rememberCellTypes(["users/alice": ["zip": "null"]]) + #expect(try statements(driver, table: "users", columns: columns, changes: [ + change(.update, cells: [(3, "zip", .text("12345"))], original: [.text("alice"), .null, .null, .null]) + ]) == [#"PATCH users/alice?updateMask=zip {"zip":"12345"}"#]) + } + + @Test("An ID or field name the request line would split is escaped or quoted") + func awkwardNames() throws { + let written = try statements(makeDriver(), table: "users", columns: ["__name__", "a,b"], changes: [ + change(.update, cells: [(1, "a,b", .text("x"))], original: [.text("John Smith"), .null]), + change(.delete, row: 1, original: [.text("a&b=c"), .null]) + ]) + #expect(written == [#"PATCH users/John%20Smith?updateMask=`a,b` {"a,b":"x"}"#, "DELETE users/a%26b%3Dc"]) + guard case .request(let patch) = try FirebaseStatement.parse(written[0]) else { + Issue.record("Expected a request line") + return + } + #expect(patch.path == "users/John Smith") + #expect(patch.queryValue("updateMask").map(FirestoreQueryBuilder.maskFields) == ["a,b"]) + } + + @Test("Undoing a delete restores the ID, leaves out missing fields, and types each value by its own field") + func restoreDeleted() throws { + let driver = makeDriver() + let columns = ["__name__", "age", "name", "zip", "tags"] + driver.rememberColumnTypes("users", columns: columns, types: ["string", "integer", "string", "integer", "array"]) + driver.rememberCellTypes(["users/alice": ["age": "integer", "zip": "string"]]) + let restored = restore(driver, table: "users", columns: columns, + rows: [[.text("alice"), .text("30"), .null, .text("02134"), .null]], absent: [0: [2, 4]]) + #expect(restored == [#"POST users?documentId=alice {"age":30,"zip":"02134"}"#]) + #expect(restore(driver, table: "users", columns: columns, rows: [[.text(""), .text("1"), .null, .null, .null]]) == nil) + } + + @Test("A new row's __name__ is an ID in this collection, never a path elsewhere") + func insertStaysInCollection() { + #expect(throws: PluginRowWriteRefusal.self) { + try statements(makeDriver(), table: "users", columns: ["__name__", "n"], + changes: [change(.insert)], inserted: [0: [.text("admins/root"), .text("1")]]) + } + } + + @Test("A whole document carries its path and refuses a rename") + func documents() throws { + let driver = makeDriver() + let text = FirebasePluginDriver.documentText(path: "users/alice", fields: ["age": .integer(30)]) + let edited = text.replacingOccurrences(of: "30", with: "31") + #expect(try driver.documentWriteStatement(PluginDocumentWrite( + table: "users", schema: nil, operation: .replace(original: text, edited: edited) + )) == #"PATCH users/alice {"age":31}"#) + let renamed = edited.replacingOccurrences(of: "users/alice", with: "users/bob") + #expect(throws: FirebaseError.self) { + try driver.documentWriteStatement(PluginDocumentWrite(table: "users", schema: nil, operation: .replace(original: text, edited: renamed))) + } + #expect(try driver.documentWriteStatement(PluginDocumentWrite( + table: "users", schema: nil, operation: .insert(document: #"{"__name__": "carol", "when": {"$timestamp": "2026-10-12T08:00:00Z"}}"#) + )) == #"POST users?documentId=carol {"when":{"$timestamp":"2026-10-12T08:00:00Z"}}"#) + } + + @Test("Filters travel in the browse statement") + func browseStatement() throws { + let statement = try #require(makeDriver().buildFilteredQuery( + table: "users", schema: nil, + queryFilters: [PluginQueryFilter(column: "age", op: ">", value: "3", secondValue: nil, elementScope: nil)], + logicMode: "OR", sortColumns: [(columnIndex: 1, ascending: false)], + columns: columns, limit: 50, offset: 100, columnKinds: [:] + )) + guard case .browse(let spec) = try FirebaseStatement.parse(statement) else { + Issue.record("Expected a browse statement") + return + } + #expect(spec == FirebaseBrowseSpec(table: "users", filters: [FirebaseFilterSpec(column: "age", op: ">", value: "3")], + logicMode: "OR", sorts: [FirebaseSortSpec(column: "age", ascending: false)], limit: 50, offset: 100)) + } + + @Test("Export moves fields outside the first page's columns into __extra__") + func exportAlignment() { + let aligned = FirebasePluginDriver.align( + [.text("bob"), .text("x"), .text("1")], columns: ["__name__", "late", "age"], to: ["__name__", "age", "__extra__"] + ) + #expect(aligned == [.text("bob"), .text("1"), .text(#"{"late":"x"}"#)]) + } +} + +struct FirebaseRealtimeDatabaseWriteTests { + private let columns = ["$key", "calls", "name", "$value"] + + @Test("Child edits patch the child, typed by the cell, and a removal writes null") + func update() throws { + let driver = makeDriver(realtimeDatabase: true) + driver.rememberCellTypes(["operators/op_1": ["calls": "number", "name": "string"]]) + let written = try statements(driver, table: "operators", columns: columns, changes: [ + change(.update, cells: [(1, "calls", .text("4"))], original: [.text("op_1"), .text("3"), .text("Ann"), .null], absent: [2]) + ]) + #expect(written == [#"PATCH operators/op_1 {"calls":4,"name":null}"#]) + } + + @Test("A plain node's row is the node itself, and a child with the node's name is still a child") + func plainValues() throws { + let driver = makeDriver(realtimeDatabase: true) + let hostTypes = ["$value": "boolean"] + let node = try statements(driver, table: "maintenance", columns: ["$key", "$value"], changes: [ + change(.update, cells: [(1, "$value", .text("true"))], original: [.text("/maintenance"), .text("false")]) + ], hostTypes: hostTypes) + #expect(node == ["PUT maintenance true"]) + let child = try statements(driver, table: "maintenance", columns: ["$key", "$value"], changes: [ + change(.update, cells: [(1, "$value", .text("true"))], original: [.text("maintenance"), .text("false")]) + ], hostTypes: hostTypes) + #expect(child == ["PUT maintenance/maintenance true"]) + } + + @Test("Undoing a delete puts a child back under its key, and a plain node back in place") + func restoreDeleted() { + let driver = makeDriver(realtimeDatabase: true) + driver.rememberCellTypes(["config/maintenance": ["$value": "boolean"], "operators/op_1": ["calls": "number"]]) + #expect(restore(driver, table: "config/maintenance", columns: ["$key", "$value"], rows: [[.text("/config/maintenance"), .text("false")]]) + == ["PUT config/maintenance false"]) + #expect(restore(driver, table: "operators", columns: ["$key", "calls", "$value"], + rows: [[.text("op_1"), .text("3"), .null]], absent: [0: [2]]) == [#"PUT operators/op_1 {"calls":3}"#]) + #expect(restore(driver, table: "operators", columns: ["$key", "calls"], rows: [[.text(""), .text("3")]]) == nil) + } + + @Test("Replacing or deleting the whole database is refused before anything is sent") + func wholeDatabase() async { + let driver = makeDriver(realtimeDatabase: true) + for statement in ["PUT / null", "PUT / {}", "DELETE /"] { + do { + _ = try await driver.execute(query: statement) + Issue.record("Expected \(statement) to be refused") + } catch let error as FirebaseError { + guard case .unsupported = error else { + Issue.record("Expected a refusal for \(statement), got \(error)") + continue + } + } catch { + Issue.record("Unexpected \(error)") + } + } + } + + @Test("New children go under their key, or a generated one, and empty ones are refused") + func inserts() throws { + let driver = makeDriver(realtimeDatabase: true) + #expect(try statements(driver, table: "/", columns: columns, changes: [change(.insert)], + inserted: [0: [.text("op_9"), .text("0"), .text("Zed"), .null]]) == [#"PUT op_9 {"calls":0,"name":"Zed"}"#]) + #expect(try statements(driver, table: "chats", columns: ["$key", "$value"], changes: [change(.insert)], + inserted: [0: [.text(""), .text("hello")]]) == [#"POST chats "hello""#]) + #expect(throws: PluginRowWriteRefusal.self) { + try statements(driver, table: "chats", columns: ["$key", "$value"], changes: [change(.insert)], inserted: [0: [.text("a.b"), .text("1")]]) + } + #expect(throws: PluginRowWriteRefusal.self) { + try statements(driver, table: "chats", columns: ["$key", "$value"], changes: [change(.insert)], inserted: [0: [.text("k"), .null]]) + } + } + + @Test("A node is edited as $key and $value, and bare JSON is pushed") + func documents() throws { + let driver = makeDriver(realtimeDatabase: true) + let text = FirebasePluginDriver.nodeText(path: "operators/op_1", value: .object(["calls": .int(3)])) + #expect(try driver.documentWriteStatement(PluginDocumentWrite( + table: "operators", schema: nil, operation: .replace(original: text, edited: text.replacingOccurrences(of: "3", with: "5")) + )) == #"PUT operators/op_1 {"calls":5}"#) + #expect(try driver.documentWriteStatement(PluginDocumentWrite( + table: "operators", schema: nil, operation: .insert(document: #"{"calls": 1}"#) + )) == #"POST operators {"calls":1}"#) + } +} + +/// Drives the whole driver against the Firebase emulators when their standard variables are set. +private enum EmulatorHosts { + static let firestore = ProcessInfo.processInfo.environment["FIRESTORE_EMULATOR_HOST"] + static let database = ProcessInfo.processInfo.environment["FIREBASE_DATABASE_EMULATOR_HOST"] +} + +@Suite(.enabled(if: EmulatorHosts.firestore != nil && EmulatorHosts.database != nil), .serialized) +struct FirebaseDriverEmulatorTests { + @Test("Firestore: connect, write from the grid, browse, filter, count and guard a stale edit") + func firestore() async throws { + let driver = makeDriver(fields: [ + FirebaseFieldID.authMethod: "emulator", FirebaseFieldID.projectId: "demo-tablepro", + FirebaseFieldID.emulatorHost: try #require(EmulatorHosts.firestore) + ]) + try await driver.connect() + let table = "drv_\(UUID().uuidString.prefix(6))" + _ = try await driver.execute(query: #"POST \#(table)?documentId=alice {"age": 30, "name": "Alice"}"#) + _ = try await driver.execute(query: #"POST \#(table)?documentId=bob {"age": 40}"#) + #expect(try await driver.fetchTables(schema: nil).map(\.name).contains(table)) + + let page = try await driver.execute(query: try #require(driver.buildBrowseQuery( + table: table, sortColumns: [], columns: [], limit: 10, offset: 0 + ))) + #expect(page.columns == ["__name__", "age", "name"]) + #expect(page.rowLocators == ["\(table)/alice", "\(table)/bob"]) + #expect(page.absentCells == [1: [2]]) + + let filtered = try await driver.execute(query: try #require(driver.buildFilteredQuery( + table: table, schema: nil, + queryFilters: [PluginQueryFilter(column: "age", op: ">=", value: "35", secondValue: nil, elementScope: nil)], + logicMode: "AND", sortColumns: [], columns: page.columns, limit: 10, offset: 0, columnKinds: [:] + ))) + #expect(filtered.rows.map { $0[0] } == [.text("bob")]) + #expect(try await driver.fetchExactRowCount(table: table, schema: nil, queryFilters: [], logicMode: "AND") == 2) + + let original = try #require(try await driver.fetchDocument(table: table, schema: nil, locator: "\(table)/alice")) + _ = try await driver.execute(query: #"PATCH \#(table)/alice?updateMask=age {"age": 31}"#) + await #expect(throws: FirebaseError.self) { + try await driver.executeDocumentWrite(PluginDocumentWrite( + table: table, schema: nil, operation: .replace(original: original, edited: original.replacingOccurrences(of: "Alice", with: "Ally")) + )) + } + _ = try await driver.execute(query: "DELETE \(table)/alice") + _ = try await driver.execute(query: "DELETE \(table)/bob") + driver.disconnect() + } + + @Test("Realtime Database: connect, write, browse in key order and guard a stale node edit") + func realtimeDatabase() async throws { + let driver = makeDriver(realtimeDatabase: true, fields: [ + FirebaseFieldID.authMethod: "emulator", FirebaseFieldID.projectId: "demo-tablepro", + FirebaseFieldID.emulatorHost: try #require(EmulatorHosts.database) + ]) + try await driver.connect() + let node = "drv_\(UUID().uuidString.prefix(6))" + _ = try await driver.execute(query: #"PUT \#(node) {"b": {"n": 2}, "a": {"n": 1}, "c": "plain"}"#) + let page = try await driver.execute(query: try #require(driver.buildBrowseQuery(table: node, sortColumns: [], columns: [], limit: 2, offset: 1))) + #expect(page.columns == ["$key", "n", "$value"]) + #expect(page.rows.map { $0[0] } == [.text("b"), .text("c")]) + #expect(try await driver.fetchExactRowCount(table: node, schema: nil, queryFilters: [], logicMode: "AND") == 3) + + let original = try #require(try await driver.fetchDocument(table: node, schema: nil, locator: "\(node)/a")) + try await driver.executeDocumentWrite(PluginDocumentWrite( + table: node, schema: nil, operation: .replace(original: original, edited: original.replacingOccurrences(of: "\"n\": 1", with: "\"n\": 10")) + )) + await #expect(throws: FirebaseError.self) { + try await driver.executeDocumentWrite(PluginDocumentWrite( + table: node, schema: nil, operation: .replace(original: original, edited: original) + )) + } + _ = try await driver.execute(query: "DELETE \(node)") + driver.disconnect() + } +} diff --git a/project.yml b/project.yml index bee8ae0513..eb806f0ff1 100644 --- a/project.yml +++ b/project.yml @@ -567,6 +567,12 @@ targets: - Plugins/EtcdDriverPlugin/EtcdServerFault.swift - Plugins/EtcdDriverPlugin/EtcdServerTrust.swift - Plugins/EtcdDriverPlugin/EtcdStatementGenerator.swift + - Plugins/FirebaseDriverPlugin/FirebaseCredentialFactory.swift + - Plugins/FirebaseDriverPlugin/FirebasePlugin.swift + - Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Editing.swift + - Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Execution.swift + - Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Metadata.swift + - Plugins/FirebaseDriverPlugin/FirebasePluginDriver.swift - Plugins/TrinoDriverPlugin/TrinoConnectionFailure.swift - Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift - Plugins/TrinoDriverPlugin/TrinoRowEditValues.swift @@ -915,7 +921,7 @@ targets: dependencies: - target: TablePro - package: TableProCore - products: [CSQLite, TableProConnectionLibrary, TableProDocumentPath, TableProGeometry, TableProJavaScriptText, TableProLogRedaction, TableProMSSQLCore, TableProNumberFormatting, TableProR2SQLCore, TableProSQLGrammar, TableProSSHTransport, TableProTabular, TableProTabularIO, TableProTLSClientIdentity, TableProTrinoCore, TableProWeaviateCore] + products: [CSQLite, TableProConnectionLibrary, TableProDocumentPath, TableProFirebaseCore, TableProGeometry, TableProJavaScriptText, TableProLogRedaction, TableProMSSQLCore, TableProNumberFormatting, TableProR2SQLCore, TableProSQLGrammar, TableProSSHTransport, TableProTabular, TableProTabularIO, TableProTLSClientIdentity, TableProTrinoCore, TableProWeaviateCore] # The Kafka integration suite drives the real driver, so the test target links what # the plugin target links: zstd for decompression and NIO for the transport. - package: zstd @@ -1608,6 +1614,19 @@ targets: - package: TableProCore product: TableProDocumentPath + FirebaseDriverPlugin: + templates: [DriverPlugin] + templateAttributes: + folder: FirebaseDriverPlugin + principalClass: FirebasePlugin + dependencies: + - package: TableProCore + product: TableProGoogleCloud + - package: TableProCore + product: TableProFirebaseCore + - package: TableProCore + product: TableProLogRedaction + WeaviateDriverPlugin: templates: [DriverPlugin] templateAttributes: @@ -1635,6 +1654,7 @@ aggregateTargets: - DynamoDBDriverPlugin - ElasticsearchDriverPlugin - EtcdDriverPlugin + - FirebaseDriverPlugin - HanaDriver - JSONExport - JSONImport